Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6aa7e2b18 | |||
| 23f0a53ca6 | |||
| a14fe3b293 | |||
| c5bd875089 | |||
| 507d5dcef9 | |||
| faeb1b04d8 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ let
|
|||||||
};
|
};
|
||||||
|
|
||||||
desc = prJobsets // {
|
desc = prJobsets // {
|
||||||
"main" = mkFlakeJobset "main";
|
"master" = mkFlakeJobset "master";
|
||||||
"nixos-26.05" = mkFlakeJobset "nixos-26.05";
|
"nixos-25.05" = mkFlakeJobset "nixos-25.05";
|
||||||
"nixos-25.11" = mkFlakeJobset "nixos-25.11";
|
"nixos-25.11" = mkFlakeJobset "nixos-25.11";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ build:
|
|||||||
|
|
||||||
sphinx:
|
sphinx:
|
||||||
configuration: docs/conf.py
|
configuration: docs/conf.py
|
||||||
fail_on_warning: true
|
|
||||||
|
|
||||||
formats:
|
formats:
|
||||||
- pdf
|
- pdf
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
[rstcheck]
|
|
||||||
ignore_messages = Hyperlink target ".*" is not referenced.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[default.extend-identifiers]
|
|
||||||
reportd = "reportd"
|
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
# ![Simple Nixos MailServer][logo]
|
# ![Simple Nixos MailServer][logo]
|
||||||
|
|
||||||

|

|
||||||
[](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/main)
|
[](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.11
|
||||||
* Use the [`nixos-26.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.11) branch
|
* Use the [SNM branch `nixos-25.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.11)
|
||||||
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/)
|
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-25.11/)
|
||||||
* [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.11/release-notes.html#nixos-25-11)
|
||||||
|
* For NixOS 25.05
|
||||||
|
* Use the [SNM branch `nixos-25.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.05)
|
||||||
|
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/)
|
||||||
|
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/release-notes.html#nixos-25-05)
|
||||||
* For NixOS 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
|
||||||
@@ -42,8 +46,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
|
||||||
@@ -64,8 +66,13 @@ 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)
|
||||||
|
* 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 +93,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
|
||||||
|
|
||||||
|
|||||||
+373
-650
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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 ];
|
||||||
|
}
|
||||||
@@ -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 ];
|
||||||
|
}
|
||||||
@@ -9,15 +9,6 @@ might help you accomplish your goals. If not, consider contributing a guide!
|
|||||||
|
|
||||||
If this is your first mailserver, consider the following:
|
If this is your first mailserver, consider the following:
|
||||||
|
|
||||||
- Configure regular, automatic `backups <backup-guide.html>`_.
|
- Set up `backups <backup-guide.html>`_.
|
||||||
- Enable `fulltext search <fts.html>`_ to let clients that don’t sync all
|
- Enable `DMARC reporting <options.html#mailserver-dmarcreporting>`_ to be a
|
||||||
mail efficiently find messages, by performing searches directly on the server.
|
good citizen in the mail ecosystem.
|
||||||
- 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
@@ -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
|
|
||||||
|
|||||||
+17
-11
@@ -5,17 +5,23 @@ 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 don’t forget to ``chown`` them
|
your backup solution does not preserve the owner of the files don’t
|
||||||
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``).
|
||||||
|
|
||||||
|
If you enabled ``enableManageSieve`` then you also may want to backup
|
||||||
|
``/var/sieve`` or whatever you have specified as ``sieveDirectory``.
|
||||||
|
The same considerations regarding file ownership apply as for the
|
||||||
|
Maildir.
|
||||||
|
|
||||||
To backup spam and ham training data, backup ``/var/lib/redis-rspamd``.
|
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
|
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever
|
||||||
specified as :option:`mailserver.dkim.keyDirectory`). If you should lose those
|
you specified as ``dkimKeyDirectory``). If you should lose those don’t
|
||||||
don’t worry, new ones will be created on the fly. But you will need to update
|
worry, new ones will be created on the fly. But you will need to repeat
|
||||||
the DKIM TXT records to reflect the new key material.
|
step ``B)5`` and correct all the ``dkim`` keys.
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
@@ -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" ];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|||||||
+5
-16
@@ -21,35 +21,24 @@ Welcome to NixOS Mailserver's documentation!
|
|||||||
options
|
options
|
||||||
migrations
|
migrations
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 1
|
|
||||||
:caption: Account backends
|
|
||||||
|
|
||||||
ldap
|
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
:caption: Features
|
:caption: Features
|
||||||
|
|
||||||
dkim
|
|
||||||
fts
|
fts
|
||||||
|
ldap
|
||||||
srs
|
srs
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 0
|
:maxdepth: 0
|
||||||
:caption: How-to
|
:caption: How-to
|
||||||
|
|
||||||
autodiscovery
|
|
||||||
backup-guide
|
backup-guide
|
||||||
flakes
|
add-radicale
|
||||||
|
add-roundcube
|
||||||
rspamd-tuning
|
rspamd-tuning
|
||||||
|
flakes
|
||||||
.. toctree::
|
autodiscovery
|
||||||
:maxdepth: 0
|
|
||||||
:caption: Integrations
|
|
||||||
|
|
||||||
radicale
|
|
||||||
roundcube
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
==================
|
==================
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
mailserver = {
|
|
||||||
ldap = {
|
|
||||||
attributes = {
|
|
||||||
uuid = "entryUUID";
|
|
||||||
username = "uid";
|
|
||||||
password = "userPassword";
|
|
||||||
mail = "mail";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
+11
-193
@@ -5,192 +5,10 @@ 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
|
to make changes that require you to complete manual migration steps before you
|
||||||
can deploy a new version of NixOS mailserver.
|
can deploy a new version of NixOS mailserver.
|
||||||
|
|
||||||
The initial :option:`mailserver.stateVersion` value should be copied from the
|
The initial `mailserver.stateVersion` value should be copied from the setup
|
||||||
setup guide that you used to initially set up your mail server. If in doubt you
|
guide that you used to initially set up your mail server. If in doubt you can
|
||||||
can always initialize it at ``1`` and walk through all assertions, that might
|
always initialize it at `1` and walk through all assertions, that might apply
|
||||||
apply to your setup.
|
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
|
|
||||||
user’s 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
|
NixOS 25.11
|
||||||
-----------
|
-----------
|
||||||
@@ -219,19 +37,19 @@ This migration is required for every configuration.
|
|||||||
|
|
||||||
For remediating this issue the following steps are required:
|
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
|
1. Copy the `migration script <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/migrations/nixos-mailserver-migration-03.py>`_ script to your mailserver
|
||||||
and make it executable:
|
and make it executable:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-03.py
|
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/master/migrations/nixos-mailserver-migration-03.py
|
||||||
chmod +x nixos-mailserver-migration-03.py
|
chmod +x nixos-mailserver-migration-03.py
|
||||||
|
|
||||||
2. Stop the ``dovecot.service``.
|
2. Stop the ``dovecot2.service``.
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
systemctl stop dovecot.service
|
systemctl stop dovecot2.service
|
||||||
|
|
||||||
3. Create a backup or snapshot of your ``mailserver.mailDirectory``, so you can restore
|
3. Create a backup or snapshot of your ``mailserver.mailDirectory``, so you can restore
|
||||||
should anything go wrong.
|
should anything go wrong.
|
||||||
@@ -254,7 +72,7 @@ For remediating this issue the following steps are required:
|
|||||||
|
|
||||||
5. Review the commands. They should be
|
5. Review the commands. They should be
|
||||||
|
|
||||||
- create a ``mail`` directory for each account,
|
- create a ``mail`` directory for each accounnt,
|
||||||
- move maildir contents from the parent directory into it,
|
- move maildir contents from the parent directory into it,
|
||||||
- suggest removal of files that do not belong to the maildir
|
- suggest removal of files that do not belong to the maildir
|
||||||
|
|
||||||
@@ -283,8 +101,8 @@ This migration is required if you both:
|
|||||||
|
|
||||||
For remediating this issue the following steps are required:
|
For remediating this issue the following steps are required:
|
||||||
|
|
||||||
1. Stop ``dovecot.service``.
|
1. Stop ``dovecot2.service``.
|
||||||
2. Move ``/var/vmail/ldap`` below your ``mailserver.mailDirectory``.
|
2. Move ``/var/vmail/ldap`` below your ``m̀ailserver.mailDirectory``.
|
||||||
3. Update the ``mailserver.stateVersion`` to ``2``.
|
3. Update the ``mailserver.stateVersion`` to ``2``.
|
||||||
|
|
||||||
#1 Initialization
|
#1 Initialization
|
||||||
|
|||||||
@@ -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
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
+3
-94
@@ -1,97 +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
|
NixOS 25.11
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
@@ -106,14 +15,14 @@ NixOS 25.11
|
|||||||
1024 bit keys should not be considered valid any longer.
|
1024 bit keys should not be considered valid any longer.
|
||||||
- IMAP access over port ``143/tcp`` is now default disabled in line
|
- 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``
|
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
|
instead. If you still require this feature you can reenable it using
|
||||||
``mailserver.enableImap``, but it is scheduled for removal after the 25.11
|
``mailserver.enableImap``, but it is scheduled for removal after the 25.11
|
||||||
release.
|
release.
|
||||||
- SMTP server and client now support and prefer a hybrid key exchange
|
- SMTP server and client now support and prefer a hybrid key exchange
|
||||||
(X25519MLKEM768)
|
(X25519MLKEM768)
|
||||||
- SMTP access over STARTTLS on port ``587/tcp`` is now default disabled in line
|
- 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
|
with `RFC 8314 3.3`_. If you still require this feature you can renable it using
|
||||||
using ``mailserver.enableSubmission``.
|
``mailserver.enableSubmission``.
|
||||||
- DMARC reports are now sent with the ``noreply-dmarc`` localpart from the
|
- DMARC reports are now sent with the ``noreply-dmarc`` localpart from the
|
||||||
system domain.
|
system domain.
|
||||||
- DANE and MTA-STS are now validated for outgoing SMTP connections using
|
- DANE and MTA-STS are now validated for outgoing SMTP connections using
|
||||||
|
|||||||
@@ -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
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+163
-230
@@ -1,5 +1,3 @@
|
|||||||
.. _setup-guide:
|
|
||||||
|
|
||||||
Setup Guide
|
Setup Guide
|
||||||
===========
|
===========
|
||||||
|
|
||||||
@@ -7,306 +5,241 @@ 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.11/nixos-mailserver-nixos-25.11.tar.gz";
|
||||||
|
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command:
|
||||||
|
# release="nixos-25.11"; 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;
|
||||||
|
stateVersion = 3;
|
||||||
|
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 IP’s 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
|
|
||||||
|
|
||||||
|
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
|
||||||
|
"p=<really-long-key>" ) ; ----- DKIM key mail for nixos.org
|
||||||
|
|
||||||
DKIM record
|
where ``really-long-key`` is your public key.
|
||||||
^^^^^^^^^^^
|
|
||||||
|
|
||||||
On system activation a `DKIM`_ keypair for ``example.com`` was generated. The
|
Based on the content of this file, we can add a ``DKIM`` record to the
|
||||||
mail server uses this key to sign outgoing emails, allowing receiving servers to
|
domain ``example.com``.
|
||||||
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
|
=========================== ===== ==== ================================================
|
||||||
|
Name (Subdomain) TTL Type Value
|
||||||
|
=========================== ===== ==== ================================================
|
||||||
|
mail._domainkey.example.com 10800 TXT ``v=DKIM1; k=rsa; s=email; p=<really-long-key>``
|
||||||
|
=========================== ===== ==== ================================================
|
||||||
|
|
||||||
Now, check ``/var/dkim/example.com.mail.txt``, which contains the proposed DNS
|
You can check this with
|
||||||
record for the ``mail`` DKIM selector.
|
|
||||||
|
|
||||||
.. code-block:: none
|
::
|
||||||
|
|
||||||
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
|
$ nix-shell -p bind --command "host -t txt mail._domainkey.example.com"
|
||||||
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7hSess/UgEjaaq/NDn5KtW2iZzYljhf45DH3tN/kqcJ04JJk/Z1rS7CMJQ/pYZSSnQOju0H25uOtODvhqXPDxDdtCyDSrx54z/38lGNtA76/iWy/ikjb9hEkb2k3HuKex3P4KhhOC1pytDEFnh/T2aBxPNOigc/cpqm1U9RbnAwvArtx9dgOAgiV8rOIgPgyrPw1B3cJG3hgFYU2"
|
mail._domainkey.example.com descriptive text "v=DKIM1;p=<really-long-key>"
|
||||||
"GwXMoiFQPgwm7bkjelmThqXozA7jFJfnYt49jjrIYCv8X/nQx9cNpVAv2852mhU/3uuy6sa4MPjT6RiK9BJCMyDnqSpTPCjIubL4VhGCuzp7RPBkayWnlaH0X8PWGq6BQ0eBwIDAQAB"
|
|
||||||
) ;
|
|
||||||
|
|
||||||
Based on the content of this file, we can create the DKIM TXT record for the
|
Note that it can take a while until a DNS entry is propagated.
|
||||||
``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::
|
Set a ``DMARC`` record
|
||||||
:header: "Name", "TTL", "Type", "Value"
|
^^^^^^^^^^^^^^^^^^^^^^
|
||||||
:widths: 30, 10, 10, 50
|
|
||||||
|
|
||||||
mail._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB
|
Add a ``DMARC`` record to the domain ``example.com``.
|
||||||
|
|
||||||
.. code-block:: console
|
======================== ===== ==== ====================
|
||||||
|
Name (Subdomain) TTL Type Value
|
||||||
|
======================== ===== ==== ====================
|
||||||
|
_dmarc.example.com 10800 TXT ``v=DMARC1; p=none``
|
||||||
|
======================== ===== ==== ====================
|
||||||
|
|
||||||
$ nix-shell -p dig --command "dig @ns1.example.org TXT mail._domainkey.example.com +short"
|
You can check this with
|
||||||
"v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB"
|
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
DMARC record
|
$ nix-shell -p bind --command "host -t TXT _dmarc.example.com"
|
||||||
^^^^^^^^^^^^
|
_dmarc.example.com descriptive text "v=DMARC1; p=none"
|
||||||
|
|
||||||
Finally, DMARC lets you define a policy for how strictly SPF and DKIM should be
|
Note that it can take a while until a DNS entry is propagated.
|
||||||
checked and how to handle validation failures. For a new server, it’s important
|
|
||||||
to have a DMARC record in place, even if it doesn’t enforce any actions yet,
|
|
||||||
because it improves deliverability by showing receiving servers that your domain
|
|
||||||
is properly managed and reducing the risk of email spoofing.
|
|
||||||
|
|
||||||
.. csv-table::
|
|
||||||
:header: "Name", "TTL", "Type", "Value"
|
|
||||||
:widths: 30, 10, 10, 50
|
|
||||||
|
|
||||||
_dmarc.example.com., 86400, TXT, v=DMARC1; p=none;
|
|
||||||
|
|
||||||
Verify propagation one final time.
|
|
||||||
|
|
||||||
.. code-block:: console
|
|
||||||
|
|
||||||
$ nix-shell -p dig --command "dig @ns1.example.org TXT _dmarc.example.com +short"
|
|
||||||
"v=DMARC1; p=none"
|
|
||||||
|
|
||||||
|
|
||||||
Test your Setup
|
Test your Setup
|
||||||
~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Write an email to your aunt — she’s 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
|
||||||
|
your setup, but if you followed the steps closely then everything should
|
||||||
|
be awesome!
|
||||||
|
|
||||||
.. _mail-tester.com: https://mail-tester.com/
|
Next steps (optional)
|
||||||
.. _MXToolbox: https://mxtoolbox.com/
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Take a look through our `Advanced Configurations <advanced-configurations.html>`_.
|
||||||
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/
|
|
||||||
|
|||||||
Generated
+12
-12
@@ -19,15 +19,15 @@
|
|||||||
"flake-compat": {
|
"flake-compat": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767039857,
|
"lastModified": 1761588595,
|
||||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
|
||||||
"owner": "NixOS",
|
"owner": "edolstra",
|
||||||
"repo": "flake-compat",
|
"repo": "flake-compat",
|
||||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
|
||||||
"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": 1763319842,
|
||||||
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
"narHash": "sha256-YG19IyrTdnVn0l3DvcUYm85u3PaqBt6tI6VvolcuHnA=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "git-hooks.nix",
|
"repo": "git-hooks.nix",
|
||||||
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
"rev": "7275fa67fbbb75891c16d9dee7d88e58aea2d761",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -79,16 +79,16 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1779622335,
|
"lastModified": 1764020296,
|
||||||
"narHash": "sha256-ViA62qtL5za7V3d5I8OA9q9JcFhsVAiL5jVHwEclWqk=",
|
"narHash": "sha256-6zddwDs2n+n01l+1TG6PlyokDdXzu/oBmEejcH5L5+A=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "705e9929918b43bd7b715dc0a878ac870449bb03",
|
"rev": "a320ce8e6e2cc6b4397eef214d202a50a4583829",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixos-26.05-small",
|
"ref": "nixos-25.11-small",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +12,7 @@
|
|||||||
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-25.11-small";
|
||||||
blobs = {
|
blobs = {
|
||||||
url = "gitlab:simple-nixos-mailserver/blobs";
|
url = "gitlab:simple-nixos-mailserver/blobs";
|
||||||
flake = false;
|
flake = false;
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
releases = [
|
releases = [
|
||||||
{
|
{
|
||||||
name = "unstable";
|
name = "nixos-25.11";
|
||||||
nixpkgs = nixpkgs;
|
nixpkgs = nixpkgs;
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,6 @@
|
|||||||
"logo\\.png"
|
"logo\\.png"
|
||||||
"conf\\.py"
|
"conf\\.py"
|
||||||
"Makefile"
|
"Makefile"
|
||||||
".*\\.nix"
|
|
||||||
".*\\.rst"
|
".*\\.rst"
|
||||||
];
|
];
|
||||||
buildInputs = [
|
buildInputs = [
|
||||||
@@ -150,13 +149,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 +166,9 @@
|
|||||||
files = "\\.rst$";
|
files = "\\.rst$";
|
||||||
};
|
};
|
||||||
|
|
||||||
# spell checking
|
|
||||||
typos = {
|
|
||||||
enable = true;
|
|
||||||
settings.configPath = ".typos.toml";
|
|
||||||
};
|
|
||||||
|
|
||||||
# nix
|
# nix
|
||||||
deadnix.enable = true;
|
deadnix.enable = true;
|
||||||
nixfmt.enable = true;
|
nixfmt-rfc-style.enable = true;
|
||||||
|
|
||||||
# python
|
# python
|
||||||
pyright.enable = true;
|
pyright.enable = true;
|
||||||
|
|||||||
+37
-98
@@ -5,62 +5,31 @@
|
|||||||
}:
|
}:
|
||||||
|
|
||||||
let
|
let
|
||||||
mailserverRelease = "26.05";
|
mailserverRelease = "25.11";
|
||||||
nixpkgsRelease = lib.trivial.release;
|
nixpkgsRelease = lib.trivial.release;
|
||||||
releaseMismatch =
|
releaseMismatch =
|
||||||
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease;
|
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease;
|
||||||
in
|
in
|
||||||
|
|
||||||
{
|
{
|
||||||
warnings =
|
warnings = lib.optional releaseMismatch ''
|
||||||
lib.optionals releaseMismatch [
|
You are using
|
||||||
''
|
|
||||||
You are using
|
|
||||||
|
|
||||||
NixOS Mailserver version ${mailserverRelease} and
|
NixOS Mailserver version ${mailserverRelease} and
|
||||||
Nixpkgs version ${nixpkgsRelease}.
|
Nixpkgs version ${nixpkgsRelease}.
|
||||||
|
|
||||||
Using mismatched versions is likely to cause compatibility issues
|
Using mismatched versions is likely to cause compatibility issues
|
||||||
and may require migrations that make an eventual rollback tricky.
|
and may require migrations that make an eventual rollback tricky.
|
||||||
|
|
||||||
It is therefore highly recommended to use a release of
|
It is therefore highly recommended to use a release of
|
||||||
NixOS mailserver that corresponds with your chosen release of Nixpkgs.
|
NixOS mailserver that corresponds with your chosen release of Nixpkgs.
|
||||||
|
|
||||||
If you insist then you can disable this warning by adding
|
If you insist then you can disable this warning by adding
|
||||||
|
|
||||||
mailserver.enableNixpkgsReleaseCheck = false;
|
mailserver.enableNixpkgsReleaseCheck = false;
|
||||||
|
|
||||||
to your configuration.
|
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
|
# We guard all assertions by requiring mailserver to be actually enabled
|
||||||
assertions = lib.optionals config.mailserver.enable (
|
assertions = lib.optionals config.mailserver.enable (
|
||||||
@@ -69,49 +38,33 @@ in
|
|||||||
assertion = config.mailserver.stateVersion != null;
|
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.";
|
message = "The `mailserver.stateVersion` option is not set. Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html to determine the proper value to initialize it at.";
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
++ lib.optionals config.mailserver.ldap.enable [
|
||||||
{
|
{
|
||||||
assertion =
|
assertion = config.mailserver.loginAccounts == { };
|
||||||
config.mailserver.x509.useACMEHost != null
|
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts";
|
||||||
-> 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 =
|
assertion = config.mailserver.extraVirtualAliases == { };
|
||||||
config.mailserver.x509.useACMEHost != null
|
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
|
||||||
|| (
|
|
||||||
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.optionals (config.mailserver.ldap.enable && config.mailserver.mailDirectory != "/var/vmail")
|
||||||
lib.mapAttrsToList (
|
[
|
||||||
domain: domainAttrs:
|
{
|
||||||
lib.mapAttrsToList (selector: selectorAttrs: [
|
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 2;
|
||||||
{
|
message = ''
|
||||||
assertion =
|
Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.mailDirectory`.
|
||||||
selectorAttrs.keyFile != null -> (selectorAttrs.keyType == null && selectorAttrs.keyLength == null);
|
Remediation:
|
||||||
message = "${domain} DKIM selector ${selector} can only use either `keyType`, `keyLength` OR `keyFile` not both.";
|
- Stop the `dovecot2.service`
|
||||||
}
|
- Move `/var/vmail/ldap` below your `mailserver.mailDirectory`
|
||||||
]) domainAttrs.selectors
|
- Increase the `stateVersion` to 2.
|
||||||
) 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.
|
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;
|
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 3;
|
||||||
@@ -122,24 +75,10 @@ in
|
|||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
++ lib.optionals (config.mailserver.ldap.enable) [
|
++ lib.optionals (config.mailserver.certificateScheme != "acme") [
|
||||||
{
|
{
|
||||||
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 4;
|
assertion = config.mailserver.acmeCertificateName == config.mailserver.fqdn;
|
||||||
message = ''
|
message = "When the certificate scheme is not 'acme' (mailserver.certificateScheme != \"acme\"), it is not possible to define mailserver.acmeCertificateName";
|
||||||
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,6 +16,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
|
pkgs,
|
||||||
lib,
|
lib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
@@ -66,15 +67,15 @@ 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 = {
|
||||||
|
|||||||
+36
-37
@@ -24,20 +24,28 @@
|
|||||||
let
|
let
|
||||||
cfg = config.mailserver;
|
cfg = config.mailserver;
|
||||||
in
|
in
|
||||||
rec {
|
{
|
||||||
withACME = cfg.x509.useACMEHost != null;
|
# cert :: PATH
|
||||||
|
certificatePath =
|
||||||
x509CertificateFile =
|
if cfg.certificateScheme == "manual" then
|
||||||
if withACME then
|
cfg.certificateFile
|
||||||
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/fullchain.pem"
|
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
|
else
|
||||||
cfg.x509.certificateFile;
|
throw "unknown certificate scheme";
|
||||||
|
|
||||||
x509PrivateKeyFile =
|
# key :: PATH
|
||||||
if withACME then
|
keyPath =
|
||||||
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/key.pem"
|
if cfg.certificateScheme == "manual" then
|
||||||
|
cfg.keyFile
|
||||||
|
else if cfg.certificateScheme == "selfsigned" then
|
||||||
|
"${cfg.certificateDirectory}/key-${cfg.fqdn}.pem"
|
||||||
|
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then
|
||||||
|
"${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem"
|
||||||
else
|
else
|
||||||
cfg.x509.privateKeyFile;
|
throw "unknown certificate scheme";
|
||||||
|
|
||||||
passwordFiles =
|
passwordFiles =
|
||||||
let
|
let
|
||||||
@@ -45,18 +53,11 @@ rec {
|
|||||||
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.passwordFile
|
value.hashedPasswordFile
|
||||||
) cfg.accounts;
|
) cfg.loginAccounts;
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -69,23 +70,21 @@ rec {
|
|||||||
passwordFile,
|
passwordFile,
|
||||||
destination,
|
destination,
|
||||||
}:
|
}:
|
||||||
pkgs.writeScript "append-ldap-bind-pwd-in-${name}"
|
pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
|
||||||
# bash
|
#!${pkgs.stdenv.shell}
|
||||||
''
|
set -euo pipefail
|
||||||
#!${pkgs.stdenv.shell}
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
baseDir=$(dirname ${destination})
|
baseDir=$(dirname ${destination})
|
||||||
if (! test -d "$baseDir"); then
|
if (! test -d "$baseDir"); then
|
||||||
mkdir -p $baseDir
|
mkdir -p $baseDir
|
||||||
chmod 755 $baseDir
|
chmod 755 $baseDir
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat ${file} > ${destination}
|
cat ${file} > ${destination}
|
||||||
echo -n '${prefix}' >> ${destination}
|
echo -n '${prefix}' >> ${destination}
|
||||||
cat ${passwordFile} | tr -d '\n' >> ${destination}
|
cat ${passwordFile} | tr -d '\n' >> ${destination}
|
||||||
echo -n '${suffix}' >> ${destination}
|
echo -n '${suffix}' >> ${destination}
|
||||||
chmod 600 ${destination}
|
chmod 600 ${destination}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
];
|
|
||||||
}
|
|
||||||
+352
-439
@@ -32,88 +32,116 @@ with (import ./common.nix {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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
|
|
||||||
''
|
|
||||||
#!${pkgs.stdenv.shell}
|
|
||||||
|
|
||||||
set -euo pipefail
|
# https://doc.dovecot.org/2.3/configuration_manual/home_directories_for_virtual_users/#ways-to-set-up-home-directory
|
||||||
|
# Mail directory below the home directory
|
||||||
|
dovecotMaildir =
|
||||||
|
"maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}"
|
||||||
|
+ (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}");
|
||||||
|
|
||||||
if (! test -d "${passwdDir}"); then
|
postfixCfg = config.services.postfix;
|
||||||
mkdir "${passwdDir}"
|
|
||||||
chmod 755 "${passwdDir}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Prevent world-readable password files, even temporarily.
|
ldapConfig = pkgs.writeTextFile {
|
||||||
umask 077
|
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}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
prepend_scheme() {
|
setPwdInLdapConfFile = appendLdapBindPwd {
|
||||||
case "$1" in
|
name = "ldap-conf-file";
|
||||||
{*}*) printf '%s' "$1" ;;
|
file = ldapConfig;
|
||||||
*) printf '{CRYPT}%s' "$1" ;;
|
prefix = ''dnpass = "'';
|
||||||
esac
|
suffix = ''"'';
|
||||||
}
|
passwordFile = cfg.ldap.bind.passwordFile;
|
||||||
|
destination = ldapConfFile;
|
||||||
|
};
|
||||||
|
|
||||||
for f in ${
|
genPasswdScript = pkgs.writeScript "generate-password-file" ''
|
||||||
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.accounts)
|
#!${pkgs.stdenv.shell}
|
||||||
}; do
|
|
||||||
if [ ! -f "$f" ]; then
|
|
||||||
echo "Expected password hash file $f does not exist!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
cat <<EOF > ${passwdFile}
|
set -euo pipefail
|
||||||
${lib.concatStringsSep "\n" (
|
|
||||||
lib.mapAttrsToList (
|
|
||||||
name: _:
|
|
||||||
if lib.elem name accountsWithPlaintextPasswordFiles then
|
|
||||||
"${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' config.services.dovecot2.package "doveadm"} pw)"}::::::"
|
|
||||||
else
|
|
||||||
"${name}:${"$(prepend_scheme \"$(head -n 1 ${passwordFiles."${name}"})\")"}::::::"
|
|
||||||
) cfg.accounts
|
|
||||||
)}
|
|
||||||
EOF
|
|
||||||
chown dovecot2:dovecot2 ${passwdFile}
|
|
||||||
|
|
||||||
cat <<EOF > ${userdbFile}
|
if (! test -d "${passwdDir}"); then
|
||||||
${lib.concatStringsSep "\n" (
|
mkdir "${passwdDir}"
|
||||||
lib.mapAttrsToList (
|
chmod 755 "${passwdDir}"
|
||||||
name: value:
|
fi
|
||||||
# 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
|
# Prevent world-readable password files, even temporarily.
|
||||||
# https://dovecot.org/mailman3/archives/list/dovecot@dovecot.org/thread/67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO/#67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO
|
umask 077
|
||||||
"${name}:::::::"
|
|
||||||
+ lib.optionalString (value.quota != null) "userdb_quota/user/storage_size=${value.quota}"
|
for f in ${
|
||||||
) cfg.accounts
|
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)
|
||||||
)}
|
}; do
|
||||||
EOF
|
if [ ! -f "$f" ]; then
|
||||||
chown dovecot2:dovecot2 ${userdbFile}
|
echo "Expected password hash file $f does not exist!"
|
||||||
'';
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cat <<EOF > ${passwdFile}
|
||||||
|
${lib.concatStringsSep "\n" (
|
||||||
|
lib.mapAttrsToList (
|
||||||
|
name: _: "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
|
||||||
|
) cfg.loginAccounts
|
||||||
|
)}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat <<EOF > ${userdbFile}
|
||||||
|
${lib.concatStringsSep "\n" (
|
||||||
|
lib.mapAttrsToList (
|
||||||
|
name: value:
|
||||||
|
"${name}:::::::"
|
||||||
|
+ lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}"
|
||||||
|
) cfg.loginAccounts
|
||||||
|
)}
|
||||||
|
EOF
|
||||||
|
'';
|
||||||
|
|
||||||
junkMailboxes = builtins.attrNames (
|
junkMailboxes = builtins.attrNames (
|
||||||
lib.filterAttrs (_: v: v ? "special_use" && v.special_use == "\\Junk") cfg.mailboxes
|
lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "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 =
|
||||||
@@ -126,31 +154,31 @@ let
|
|||||||
else
|
else
|
||||||
scope
|
scope
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ftsPluginSettings = {
|
||||||
|
fts = "flatcurve";
|
||||||
|
fts_languages = listToLine cfg.fullTextSearch.languages;
|
||||||
|
fts_tokenizers = listToLine [
|
||||||
|
"generic"
|
||||||
|
"email-address"
|
||||||
|
];
|
||||||
|
fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian
|
||||||
|
fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch;
|
||||||
|
fts_filters = listToLine cfg.fullTextSearch.filters;
|
||||||
|
fts_header_excludes = listToLine cfg.fullTextSearch.headerExcludes;
|
||||||
|
fts_autoindex = boolToYesNo cfg.fullTextSearch.autoIndex;
|
||||||
|
fts_enforced = cfg.fullTextSearch.enforced;
|
||||||
|
}
|
||||||
|
// (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude);
|
||||||
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.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 =
|
||||||
@@ -168,385 +196,270 @@ in
|
|||||||
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.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
|
||||||
|
|
||||||
# For compatibility with python imaplib
|
# For compatibility with python imaplib
|
||||||
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
|
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
|
||||||
|
|
||||||
services.dovecot2 = {
|
services.dovecot2 = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = pkgs.dovecot; # pin over stateVersion logic in nixox 26.05
|
enableImap = cfg.enableImap || cfg.enableImapSsl;
|
||||||
enablePAM = mkForce false;
|
enablePop3 = cfg.enablePop3 || cfg.enablePop3Ssl;
|
||||||
|
enablePAM = false;
|
||||||
|
enableQuota = true;
|
||||||
|
mailGroup = cfg.vmailGroupName;
|
||||||
|
mailUser = cfg.vmailUserName;
|
||||||
|
mailLocation = dovecotMaildir;
|
||||||
|
sslServerCert = certificatePath;
|
||||||
|
sslServerKey = keyPath;
|
||||||
|
enableDHE = lib.mkDefault false;
|
||||||
|
enableLmtp = true;
|
||||||
|
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [
|
||||||
|
"fts"
|
||||||
|
"fts_flatcurve"
|
||||||
|
];
|
||||||
|
protocols = lib.optional cfg.enableManageSieve "sieve";
|
||||||
|
|
||||||
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"
|
||||||
|
];
|
||||||
|
|
||||||
|
scripts.after = builtins.toFile "spam.sieve" ''
|
||||||
|
require "fileinto";
|
||||||
|
|
||||||
|
if header :is "X-Spam" "Yes" {
|
||||||
|
fileinto "${junkMailboxName}";
|
||||||
|
stop;
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
pipeBins = map lib.getExe [
|
||||||
|
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
|
||||||
|
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
imapsieve.mailbox = [
|
||||||
|
{
|
||||||
|
name = junkMailboxName;
|
||||||
|
causes = [
|
||||||
|
"COPY"
|
||||||
|
"APPEND"
|
||||||
|
];
|
||||||
|
before = ./dovecot/imap_sieve/report-spam.sieve;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name = "*";
|
||||||
|
from = junkMailboxName;
|
||||||
|
causes = [ "COPY" ];
|
||||||
|
before = ./dovecot/imap_sieve/report-ham.sieve;
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# https://doc.dovecot.org/2.4.3/core/settings/syntax.html
|
mailboxes = cfg.mailboxes;
|
||||||
# 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
|
extraConfig = ''
|
||||||
hostname = cfg.fqdn;
|
#Extra Config
|
||||||
|
${lib.optionalString cfg.debug.dovecot ''
|
||||||
|
mail_debug = yes
|
||||||
|
auth_debug = yes
|
||||||
|
verbose_ssl = yes
|
||||||
|
''}
|
||||||
|
|
||||||
# vmail user
|
${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) ''
|
||||||
mail_uid = cfg.storage.owner;
|
service imap-login {
|
||||||
mail_gid = cfg.storage.group;
|
inet_listener imap {
|
||||||
mail_access_groups = cfg.storage.group;
|
${
|
||||||
|
if cfg.enableImap then
|
||||||
# authentication
|
''
|
||||||
auth_mechanisms = [
|
port = 143
|
||||||
"plain"
|
''
|
||||||
"login"
|
else
|
||||||
];
|
''
|
||||||
|
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||||
# backend services
|
port = 0
|
||||||
"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";
|
|
||||||
|
|
||||||
if header :is "X-Spam" "Yes" {
|
|
||||||
fileinto "${junkMailboxName}";
|
|
||||||
stop;
|
|
||||||
}
|
}
|
||||||
'';
|
}
|
||||||
type = "after";
|
inet_listener imaps {
|
||||||
};
|
${
|
||||||
"sieve_script default" = {
|
if cfg.enableImapSsl then
|
||||||
# declarative
|
''
|
||||||
type = "default";
|
port = 993
|
||||||
name = "default";
|
ssl = yes
|
||||||
# TODO: Pre-compile Sieve scripts with 'sievec' (requires a Dovecot config in build sandbox)
|
''
|
||||||
path = "${
|
else
|
||||||
pkgs.runCommand "declarative-sieve-scripts" { } (
|
''
|
||||||
''
|
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||||
mkdir "$out"
|
port = 0
|
||||||
''
|
''
|
||||||
+ lib.concatMapAttrsStringSep "\n" (_: value: ''
|
}
|
||||||
mkdir "$out/${value.name}"
|
}
|
||||||
cp -v "${builtins.toFile "default.sieve" value.sieveScript}" "$out/${value.name}/default.sieve"
|
|
||||||
'') (lib.filterAttrs (_: value: value.sieveScript != null) cfg.accounts)
|
|
||||||
)
|
|
||||||
}/%{user}/default.sieve";
|
|
||||||
};
|
|
||||||
|
|
||||||
"sieve_script personal" = {
|
|
||||||
# managesieve
|
|
||||||
type = "personal";
|
|
||||||
# Upstream default, but we want to be explicit about it
|
|
||||||
# https://doc.dovecot.org/main/core/plugins/sieve.html#script-storage-type-personal
|
|
||||||
active_path = "~/.dovecot.sieve";
|
|
||||||
path = "~/sieve";
|
|
||||||
};
|
|
||||||
|
|
||||||
sieve_extensions = {
|
|
||||||
fileinto = true;
|
|
||||||
};
|
|
||||||
sieve_global_extensions = {
|
|
||||||
"vnd.dovecot.pipe" = true;
|
|
||||||
};
|
|
||||||
sieve_plugins = {
|
|
||||||
sieve_imapsieve = true;
|
|
||||||
sieve_extprograms = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
# imapsieve (spam/ham learning)
|
|
||||||
"mailbox ${junkMailboxName}" = {
|
|
||||||
"sieve_script spam" = {
|
|
||||||
cause = [
|
|
||||||
"APPEND"
|
|
||||||
"COPY"
|
|
||||||
];
|
|
||||||
path = ./dovecot/imap_sieve/report-spam.sieve;
|
|
||||||
type = "before";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
"imapsieve_from ${junkMailboxName}" = {
|
|
||||||
"sieve_script ham" = {
|
|
||||||
cause = "copy";
|
|
||||||
path = ./dovecot/imap_sieve/report-ham.sieve;
|
|
||||||
type = "before";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mailbox_list_layout = cfg.storage.directoryLayout;
|
|
||||||
mailbox_list_utf8 = cfg.useUTF8FolderNames;
|
|
||||||
mail_driver = "maildir";
|
|
||||||
mail_path = "~/mail";
|
|
||||||
|
|
||||||
# declarative users
|
|
||||||
"userdb declarative" = {
|
|
||||||
driver = "passwd-file";
|
|
||||||
passwd_file_path = userdbFile;
|
|
||||||
fields = {
|
|
||||||
home = "${cfg.storage.path}/%{user | domain}/%{user | username}";
|
|
||||||
inherit (cfg.storage) uid gid;
|
|
||||||
mail_index_path = mkIf (cfg.indexDir != null) "${cfg.indexDir}/%{user | domain}/%{user | username}";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
"passdb declarative" = {
|
|
||||||
driver = "passwd-file";
|
|
||||||
passwd_file_path = passwdFile;
|
|
||||||
};
|
|
||||||
|
|
||||||
})
|
|
||||||
(mkIf cfg.ldap.enable {
|
|
||||||
# ldap users
|
|
||||||
ssl_client_ca_file = cfg.ldap.caFile;
|
|
||||||
ssl_client_require_valid_cert = true;
|
|
||||||
|
|
||||||
ldap_version = 3;
|
|
||||||
ldap_uris = cfg.ldap.uris;
|
|
||||||
ldap_starttls = cfg.ldap.startTls;
|
|
||||||
ldap_auth_dn = cfg.ldap.bind.dn;
|
|
||||||
ldap_auth_dn_password = "</run/credentials/dovecot.service/ldap-bind-pw";
|
|
||||||
ldap_base = cfg.ldap.base;
|
|
||||||
ldap_scope = mkLdapSearchScope cfg.ldap.scope;
|
|
||||||
|
|
||||||
"userdb ldap" = {
|
|
||||||
driver = "ldap";
|
|
||||||
filter = cfg.ldap.dovecot.userFilter;
|
|
||||||
fields = {
|
|
||||||
home = "${cfg.storage.path}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
|
|
||||||
inherit (cfg.storage) uid gid;
|
|
||||||
mail_index_path = mkIf (
|
|
||||||
cfg.indexDir != null
|
|
||||||
) "${cfg.indexDir}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
|
|
||||||
};
|
|
||||||
ldap_connection_group = "ldap-userdb-conn";
|
|
||||||
};
|
|
||||||
"passdb ldap" = {
|
|
||||||
driver = "ldap";
|
|
||||||
filter = cfg.ldap.dovecot.passFilter;
|
|
||||||
bind = cfg.ldap.attributes.password == null;
|
|
||||||
fields = {
|
|
||||||
password = mkIf (cfg.ldap.attributes.password != null) "%{ldap:${cfg.ldap.attributes.password}}";
|
|
||||||
};
|
|
||||||
ldap_connection_group = "ldap-passdb-conn";
|
|
||||||
};
|
|
||||||
})
|
|
||||||
(mkIf cfg.quota.enable {
|
|
||||||
mail_plugins.quota = true;
|
|
||||||
|
|
||||||
"protocol imap".mail_plugins.imap_quota = true;
|
|
||||||
|
|
||||||
"service quota-status" = {
|
|
||||||
executable = toString [
|
|
||||||
"${config.services.dovecot2.package}/libexec/dovecot/quota-status"
|
|
||||||
"-p"
|
|
||||||
"postfix"
|
|
||||||
];
|
|
||||||
"unix_listener quota-status" = {
|
|
||||||
user = "postfix";
|
|
||||||
};
|
|
||||||
client_limit = 1;
|
|
||||||
vsz_limit = "${toString cfg.quotaStatusMemoryLimit} MB";
|
|
||||||
};
|
|
||||||
|
|
||||||
quota_status_success = "DUNNO";
|
|
||||||
quota_status_nouser = "DUNNO";
|
|
||||||
quota_status_overquota = "552 5.2.2 Mailbox is full";
|
|
||||||
# quota_storage_grace = "10M";
|
|
||||||
|
|
||||||
"quota user" = {
|
|
||||||
driver = "count";
|
|
||||||
storage_size = mkIf (cfg.quota.defaults.perUser != null) cfg.quota.defaults.perUser;
|
|
||||||
};
|
|
||||||
})
|
|
||||||
(mkIf cfg.fullTextSearch.enable (
|
|
||||||
{
|
|
||||||
mail_plugins = {
|
|
||||||
fts = true;
|
|
||||||
fts_flatcurve = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
"service indexer-worker" = mkIf (cfg.fullTextSearch.memoryLimit != null) {
|
|
||||||
vsz_limit = "${toString cfg.fullTextSearch.memoryLimit} MB";
|
|
||||||
};
|
|
||||||
|
|
||||||
fts_autoindex = cfg.fullTextSearch.autoIndex;
|
|
||||||
fts_driver = "flatcurve";
|
|
||||||
fts_search_add_missing = "yes";
|
|
||||||
fts_search_read_fallback = cfg.fullTextSearch.fallback;
|
|
||||||
fts_header_excludes = lib.genAttrs cfg.fullTextSearch.headerExcludes (_: true);
|
|
||||||
|
|
||||||
"fts flatcurve" = {
|
|
||||||
flatcurve_substring_search = cfg.fullTextSearch.substringSearch;
|
|
||||||
};
|
|
||||||
|
|
||||||
# languages
|
|
||||||
language_filters = lib.genAttrs cfg.fullTextSearch.filters (_: true);
|
|
||||||
language_tokenizer_address_token_maxlen = 100; # default 250 too large for Xapian
|
|
||||||
}
|
}
|
||||||
# build languages from list, the first one becomes the default language
|
''}
|
||||||
// lib.listToAttrs (
|
${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) ''
|
||||||
lib.imap0 (i: lang: {
|
service pop3-login {
|
||||||
name = "language ${lang}";
|
inet_listener pop3 {
|
||||||
value = (if i == 0 then { default = true; } else { }) // {
|
${
|
||||||
language_tokenizers = [
|
if cfg.enablePop3 then
|
||||||
"generic"
|
''
|
||||||
"email-address"
|
port = 110
|
||||||
];
|
''
|
||||||
};
|
else
|
||||||
}) cfg.fullTextSearch.languages
|
''
|
||||||
)
|
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||||
))
|
port = 0
|
||||||
(mkIf cfg.debug.dovecot {
|
''
|
||||||
mail_debug = true;
|
}
|
||||||
# https://doc.dovecot.org/2.4.3/core/config/events/filter.html#common-unified-filter-language
|
}
|
||||||
log_debug = "category=ssl OR category=auth";
|
inet_listener pop3s {
|
||||||
})
|
${
|
||||||
];
|
if cfg.enablePop3Ssl then
|
||||||
|
''
|
||||||
|
port = 995
|
||||||
|
ssl = yes
|
||||||
|
''
|
||||||
|
else
|
||||||
|
''
|
||||||
|
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||||
|
port = 0
|
||||||
|
''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''}
|
||||||
|
|
||||||
|
protocol imap {
|
||||||
|
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
|
||||||
|
mail_plugins = $mail_plugins imap_sieve
|
||||||
|
}
|
||||||
|
|
||||||
|
service imap {
|
||||||
|
vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol pop3 {
|
||||||
|
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
|
||||||
|
}
|
||||||
|
|
||||||
|
mail_access_groups = ${cfg.vmailGroupName}
|
||||||
|
|
||||||
|
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
|
||||||
|
ssl = required
|
||||||
|
ssl_min_protocol = TLSv1
|
||||||
|
ssl_prefer_server_ciphers = no
|
||||||
|
ssl_curve_list = X25519MLKEM768:X25519:prime256v1:secp384r1
|
||||||
|
|
||||||
|
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 = \
|
||||||
|
home=${cfg.mailDirectory}/%{domain}/%{username} \
|
||||||
|
uid=${builtins.toString cfg.vmailUID} \
|
||||||
|
gid=${builtins.toString cfg.vmailUID}
|
||||||
|
}
|
||||||
|
|
||||||
|
${lib.optionalString cfg.ldap.enable ''
|
||||||
|
passdb {
|
||||||
|
driver = ldap
|
||||||
|
args = ${ldapConfFile}
|
||||||
|
}
|
||||||
|
|
||||||
|
userdb {
|
||||||
|
driver = ldap
|
||||||
|
args = ${ldapConfFile}
|
||||||
|
default_fields = \
|
||||||
|
home=${cfg.mailDirectory}/ldap/%{user} \
|
||||||
|
uid=${toString cfg.vmailUID} \
|
||||||
|
gid=${toString cfg.vmailUID} \
|
||||||
|
mail=maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}${
|
||||||
|
lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/ldap/%{user}"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
''}
|
||||||
|
|
||||||
|
service auth {
|
||||||
|
unix_listener auth {
|
||||||
|
mode = 0660
|
||||||
|
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
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services.dovecot = {
|
systemd.services.dovecot = {
|
||||||
preStart = ''
|
preStart = ''
|
||||||
${genPasswdScript}
|
${genPasswdScript}
|
||||||
'';
|
''
|
||||||
reloadTriggers = lib.mkIf (!withACME) [
|
+ (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
|
||||||
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
|
genPasswdScript
|
||||||
];
|
]
|
||||||
|
++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
environment.systemPackages = [
|
environment.systemPackages =
|
||||||
config.services.dovecot2.package
|
with pkgs;
|
||||||
pkgs.openssh
|
[
|
||||||
config.services.postfix.package
|
dovecot
|
||||||
config.services.rspamd.package
|
openssh
|
||||||
];
|
postfix
|
||||||
|
rspamd
|
||||||
|
]
|
||||||
|
++ (if cfg.certificateScheme == "selfsigned" then [ openssl ] else [ ]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ in
|
|||||||
++ lib.optional cfg.enableImapSsl 993
|
++ lib.optional cfg.enableImapSsl 993
|
||||||
++ lib.optional cfg.enablePop3 110
|
++ lib.optional cfg.enablePop3 110
|
||||||
++ lib.optional cfg.enablePop3Ssl 995
|
++ lib.optional cfg.enablePop3Ssl 995
|
||||||
++ lib.optional cfg.enableManageSieve 4190;
|
++ lib.optional cfg.enableManageSieve 4190
|
||||||
|
++ lib.optional (cfg.certificateScheme == "acme-nginx") 80;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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,
|
||||||
|
options,
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
with (import ./common.nix {
|
||||||
|
inherit
|
||||||
|
config
|
||||||
|
options
|
||||||
|
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}" = {
|
||||||
|
extraDomainNames = lib.mkIf (cfg.certificateScheme == "acme") cfg.certificateDomains;
|
||||||
|
reloadServices = [
|
||||||
|
"postfix.service"
|
||||||
|
"dovecot.service"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
+36
-90
@@ -51,7 +51,7 @@ let
|
|||||||
to = name;
|
to = name;
|
||||||
in
|
in
|
||||||
map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name)
|
map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name)
|
||||||
) cfg.accounts
|
) cfg.loginAccounts
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
regex_valiases_postfix = mergeLookupTables (
|
regex_valiases_postfix = mergeLookupTables (
|
||||||
@@ -62,7 +62,7 @@ let
|
|||||||
to = name;
|
to = name;
|
||||||
in
|
in
|
||||||
map (from: { "${from}" = to; }) value.aliasesRegexp
|
map (from: { "${from}" = to; }) value.aliasesRegexp
|
||||||
) cfg.accounts
|
) cfg.loginAccounts
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ let
|
|||||||
to = name;
|
to = name;
|
||||||
in
|
in
|
||||||
map (from: { "@${from}" = to; }) value.catchAll
|
map (from: { "@${from}" = to; }) value.catchAll
|
||||||
) cfg.accounts
|
) cfg.loginAccounts
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ let
|
|||||||
mergeLookupTables lookupTables;
|
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;
|
||||||
@@ -127,18 +127,13 @@ let
|
|||||||
|
|
||||||
# denied_recipients_postfix :: [ String ]
|
# denied_recipients_postfix :: [ String ]
|
||||||
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (
|
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (
|
||||||
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.accounts)
|
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} REJECT") cfg.rejectSender;
|
||||||
sender:
|
|
||||||
"${sender} REJECT${
|
|
||||||
lib.optionalString (cfg.rejectSenderMessage != "") " ${cfg.rejectSenderMessage}"
|
|
||||||
}"
|
|
||||||
) cfg.rejectSender;
|
|
||||||
reject_senders_file = builtins.toFile "reject_senders" (
|
reject_senders_file = builtins.toFile "reject_senders" (
|
||||||
lib.concatStringsSep "\n" reject_senders_postfix
|
lib.concatStringsSep "\n" reject_senders_postfix
|
||||||
);
|
);
|
||||||
@@ -209,21 +204,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 +228,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 {
|
||||||
@@ -286,17 +279,6 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
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}
|
||||||
@@ -334,6 +316,9 @@ in
|
|||||||
message_size_limit = cfg.messageSizeLimit;
|
message_size_limit = cfg.messageSizeLimit;
|
||||||
|
|
||||||
# virtual mail system
|
# virtual mail system
|
||||||
|
virtual_uid_maps = "static:5000";
|
||||||
|
virtual_gid_maps = "static:5000";
|
||||||
|
virtual_mailbox_base = cfg.mailDirectory;
|
||||||
virtual_mailbox_domains = vhosts_file;
|
virtual_mailbox_domains = vhosts_file;
|
||||||
virtual_mailbox_maps = [
|
virtual_mailbox_maps = [
|
||||||
(mappedFile "valias")
|
(mappedFile "valias")
|
||||||
@@ -373,16 +358,14 @@ 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
|
# The X509 private key followed by the corresponding certificate
|
||||||
smtpd_tls_chain_files = [
|
smtpd_tls_chain_files = [
|
||||||
"${x509PrivateKeyFile}"
|
"${keyPath}"
|
||||||
"${x509CertificateFile}"
|
"${certificatePath}"
|
||||||
];
|
];
|
||||||
|
|
||||||
# TLS for incoming mail is optional
|
# TLS for incoming mail is optional
|
||||||
@@ -399,6 +382,10 @@ in
|
|||||||
smtpd_tls_ciphers = "high";
|
smtpd_tls_ciphers = "high";
|
||||||
smtpd_tls_mandatory_ciphers = "high";
|
smtpd_tls_mandatory_ciphers = "high";
|
||||||
|
|
||||||
|
# Exclude cipher suites with undesirable properties
|
||||||
|
smtpd_tls_exclude_ciphers = "SHA1, eNULL, aNULL";
|
||||||
|
smtpd_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL";
|
||||||
|
|
||||||
# Enable DNSSEC/DANE support for outgoing SMTP connections
|
# Enable DNSSEC/DANE support for outgoing SMTP connections
|
||||||
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
|
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
|
||||||
smtp_dns_support_level = "dnssec";
|
smtp_dns_support_level = "dnssec";
|
||||||
@@ -412,6 +399,13 @@ in
|
|||||||
smtp_tls_ciphers = "high";
|
smtp_tls_ciphers = "high";
|
||||||
smtp_tls_mandatory_ciphers = "high";
|
smtp_tls_mandatory_ciphers = "high";
|
||||||
|
|
||||||
|
# Exclude ciphersuites with undesirable properties
|
||||||
|
smtp_tls_exclude_ciphers = "SHA1, eNULL, aNULL";
|
||||||
|
smtp_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL";
|
||||||
|
|
||||||
|
# Restrict and prioritize the following curves in the given order
|
||||||
|
# Excludes curves that have no widespread support, so we don't bloat the handshake needlessly.
|
||||||
|
# https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves
|
||||||
tls_config_file =
|
tls_config_file =
|
||||||
let
|
let
|
||||||
mkGroupString = groups: concatStringsSep " / " (map (concatStringsSep ":") groups);
|
mkGroupString = groups: concatStringsSep " / " (map (concatStringsSep ":") groups);
|
||||||
@@ -421,52 +415,14 @@ in
|
|||||||
sections = {
|
sections = {
|
||||||
postfix_settings.ssl_conf = "postfix_ssl_settings";
|
postfix_settings.ssl_conf = "postfix_ssl_settings";
|
||||||
postfix_ssl_settings.system_default = "baseline_postfix_settings";
|
postfix_ssl_settings.system_default = "baseline_postfix_settings";
|
||||||
baseline_postfix_settings = {
|
baseline_postfix_settings.Groups = mkGroupString [
|
||||||
# Allow all TLSv1.3 cipher suites
|
[ "*X25519MLKEM768" ]
|
||||||
Ciphersuites = concatStringsSep ":" [
|
[ "*X25519" ]
|
||||||
"TLS_AES_256_GCM_SHA384"
|
[
|
||||||
"TLS_AES_128_GCM_SHA256"
|
"P-256"
|
||||||
"TLS_CHACHA20_POLY1305_SHA256"
|
"P-384"
|
||||||
];
|
]
|
||||||
|
];
|
||||||
# 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";
|
tls_config_name = "postfix";
|
||||||
@@ -475,16 +431,6 @@ in
|
|||||||
tls_eecdh_auto_curves = [ ];
|
tls_eecdh_auto_curves = [ ];
|
||||||
tls_ffdhe_auto_groups = [ ];
|
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
|
# As long as all cipher suites are considered safe, let the client use its preferred cipher
|
||||||
tls_preempt_cipherlist = false;
|
tls_preempt_cipherlist = false;
|
||||||
|
|
||||||
@@ -493,7 +439,7 @@ in
|
|||||||
smtpd_tls_loglevel = "1";
|
smtpd_tls_loglevel = "1";
|
||||||
|
|
||||||
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}";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,7 +61,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/
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+29
-97
@@ -26,86 +26,32 @@ let
|
|||||||
|
|
||||||
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:
|
||||||
domain,
|
|
||||||
selector,
|
|
||||||
type,
|
|
||||||
bits,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
let
|
||||||
privkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
|
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
|
||||||
pubkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.txt";
|
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
|
||||||
in
|
in
|
||||||
pkgs.writeShellScript "dkim-keygen-${domain}-${selector}" ''
|
pkgs.writeShellScript "dkim-keygen-${domain}" ''
|
||||||
if [ ! -f "${privkey}" ]
|
if [ ! -f "${privateKey}" ]
|
||||||
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 (
|
dkimDomains = lib.unique (cfg.domains ++ (lib.optionals cfg.srs.enable [ cfg.srs.domain ]));
|
||||||
# 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 = lib.mkIf cfg.enable {
|
||||||
@@ -115,7 +61,7 @@ in
|
|||||||
nativeBuildInputs = with pkgs; [ makeWrapper ];
|
nativeBuildInputs = with pkgs; [ makeWrapper ];
|
||||||
}
|
}
|
||||||
''
|
''
|
||||||
makeWrapper ${lib.getExe' rspamdPkg "rspamc"} $out/bin/rspamc \
|
makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \
|
||||||
--add-flags "-h /run/rspamd/worker-controller.sock"
|
--add-flags "-h /run/rspamd/worker-controller.sock"
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
@@ -127,7 +73,6 @@ in
|
|||||||
locals = {
|
locals = {
|
||||||
"milter_headers.conf" = {
|
"milter_headers.conf" = {
|
||||||
text = ''
|
text = ''
|
||||||
use = [ "authentication-results" ];
|
|
||||||
extended_spam_headers = true;
|
extended_spam_headers = true;
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -165,31 +110,13 @@ in
|
|||||||
};
|
};
|
||||||
"dkim_signing.conf" = {
|
"dkim_signing.conf" = {
|
||||||
text = ''
|
text = ''
|
||||||
enabled = ${lib.boolToString cfg.dkim.enable};
|
enabled = ${lib.boolToString cfg.dkimSigning};
|
||||||
# Only sign explicitly configured domains
|
path = "${cfg.dkimKeyDirectory}/$domain.$selector.key";
|
||||||
try_fallback = false;
|
selector = "${cfg.dkimSelector}";
|
||||||
# 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
|
# Don't normalize DKIM key selection for subdomains
|
||||||
use_esld = false;
|
use_esld = false;
|
||||||
|
|
||||||
domain {
|
|
||||||
${lib.concatStringsSep "\n\n" (
|
|
||||||
map (domain: ''
|
|
||||||
${domain} {
|
|
||||||
selectors [
|
|
||||||
${lib.concatStringsSep ",\n" (
|
|
||||||
map (selector: ''
|
|
||||||
{
|
|
||||||
path: "${selector.keyPath}";
|
|
||||||
selector: "${selector.selector}";
|
|
||||||
}'') dkimKeysByDomain.${domain}
|
|
||||||
)}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
'') (lib.attrNames dkimKeysByDomain)
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
"dmarc.conf" = {
|
"dmarc.conf" = {
|
||||||
@@ -256,7 +183,7 @@ in
|
|||||||
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally;
|
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally;
|
||||||
|
|
||||||
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,9 +204,9 @@ 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 dkimDomains;
|
||||||
ReadWritePaths = [ cfg.dkim.keyDirectory ];
|
ReadWritePaths = [ cfg.dkimKeyDirectory ];
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -289,7 +216,7 @@ in
|
|||||||
# 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 = toString [
|
||||||
(lib.getExe' rspamdPkg "rspamadm")
|
(lib.getExe' pkgs.rspamd "rspamadm")
|
||||||
"dmarc_report"
|
"dmarc_report"
|
||||||
"$(date -d 'yesterday' '+%Y%m%d')"
|
"$(date -d 'yesterday' '+%Y%m%d')"
|
||||||
];
|
];
|
||||||
@@ -351,6 +278,11 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
systemd.services.postfix = {
|
||||||
|
after = [ rspamdSocket ];
|
||||||
|
requires = [ rspamdSocket ];
|
||||||
|
};
|
||||||
|
|
||||||
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
|
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-11
@@ -33,20 +33,51 @@ with (import ./common.nix {
|
|||||||
|
|
||||||
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 = lib.mkIf cfg.enable {
|
||||||
|
# Create self signed certificate
|
||||||
|
systemd.services.mailserver-selfsigned-certificate =
|
||||||
|
lib.mkIf (cfg.certificateScheme == "selfsigned")
|
||||||
|
{
|
||||||
|
after = [ "local-fs.target" ];
|
||||||
|
script = ''
|
||||||
|
# Create certificates if they do not exist yet
|
||||||
|
dir="${cfg.certificateDirectory}"
|
||||||
|
fqdn="${cfg.fqdn}"
|
||||||
|
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
|
||||||
|
key="$dir/key-${cfg.fqdn}.pem";
|
||||||
|
cert="$dir/cert-${cfg.fqdn}.pem";
|
||||||
|
|
||||||
|
if [[ ! -f $key || ! -f $cert ]]; then
|
||||||
|
mkdir -p "${cfg.certificateDirectory}"
|
||||||
|
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) &&
|
||||||
|
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \
|
||||||
|
-days 3650 -out "$cert"
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
PrivateTmp = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Create maildir folder before dovecot startup
|
||||||
systemd.services.dovecot = {
|
systemd.services.dovecot = {
|
||||||
wants = certificateDeps;
|
wants = certificatesDeps;
|
||||||
after = certificateDeps;
|
after = certificatesDeps;
|
||||||
preStart =
|
preStart =
|
||||||
let
|
let
|
||||||
directories = lib.strings.escapeShellArgs (
|
directories = lib.strings.escapeShellArgs (
|
||||||
[ cfg.storage.path ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir
|
[ cfg.mailDirectory ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir
|
||||||
);
|
);
|
||||||
in
|
in
|
||||||
''
|
''
|
||||||
@@ -55,20 +86,20 @@ in
|
|||||||
# 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 "${cfg.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 = [
|
||||||
"dovecot.service"
|
"dovecot.service"
|
||||||
]
|
]
|
||||||
++ lib.optional cfg.dkim.enable "rspamd.service"
|
++ lib.optional cfg.dkimSigning "rspamd.service"
|
||||||
++ certificateDeps;
|
++ certificatesDeps;
|
||||||
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkim.enable "rspamd.service";
|
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-24
@@ -16,49 +16,112 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
|
options,
|
||||||
|
pkgs,
|
||||||
lib,
|
lib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
|
||||||
|
with (import ./common.nix {
|
||||||
|
inherit
|
||||||
|
config
|
||||||
|
options
|
||||||
|
lib
|
||||||
|
pkgs
|
||||||
|
;
|
||||||
|
});
|
||||||
|
|
||||||
|
with config.mailserver;
|
||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.mailserver;
|
vmail_user = {
|
||||||
|
name = vmailUserName;
|
||||||
|
isSystemUser = true;
|
||||||
|
uid = vmailUID;
|
||||||
|
home = mailDirectory;
|
||||||
|
createHome = true;
|
||||||
|
group = vmailGroupName;
|
||||||
|
};
|
||||||
|
|
||||||
|
virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" ''
|
||||||
|
#!${pkgs.stdenv.shell}
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Prevent world-readable paths, even temporarily.
|
||||||
|
umask 007
|
||||||
|
|
||||||
|
# Create directory to store user sieve scripts if it doesn't exist
|
||||||
|
if (! test -d "${sieveDirectory}"); then
|
||||||
|
mkdir "${sieveDirectory}"
|
||||||
|
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}"
|
||||||
|
chmod 770 "${sieveDirectory}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy user's sieve script to the correct location (if it exists). If it
|
||||||
|
# is null, remove the file.
|
||||||
|
${lib.concatMapStringsSep "\n" (
|
||||||
|
{ name, sieveScript }:
|
||||||
|
if lib.isString sieveScript then
|
||||||
|
''
|
||||||
|
if (! test -d "${sieveDirectory}/${name}"); then
|
||||||
|
mkdir -p "${sieveDirectory}/${name}"
|
||||||
|
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
|
||||||
|
chmod 770 "${sieveDirectory}/${name}"
|
||||||
|
fi
|
||||||
|
cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve"
|
||||||
|
${sieveScript}
|
||||||
|
EOF
|
||||||
|
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
|
||||||
|
''
|
||||||
|
else
|
||||||
|
''
|
||||||
|
if (test -f "${sieveDirectory}/${name}/default.sieve"); then
|
||||||
|
rm "${sieveDirectory}/${name}/default.sieve"
|
||||||
|
fi
|
||||||
|
if (test -f "${sieveDirectory}/${name}.svbin"); then
|
||||||
|
rm "${sieveDirectory}/${name}/default.svbin"
|
||||||
|
fi
|
||||||
|
''
|
||||||
|
) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))}
|
||||||
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.enable {
|
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 (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
|
map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
|
||||||
(
|
(
|
||||||
lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) (
|
lib.filter (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 = [ "dovecot.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = virtualMailUsersActivationScript;
|
||||||
|
};
|
||||||
|
enable = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -11,13 +11,12 @@ header = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
template = """
|
template = """
|
||||||
({key})=
|
|
||||||
`````{{option}} {key}
|
`````{{option}} {key}
|
||||||
|
{description}
|
||||||
|
|
||||||
{type}
|
{type}
|
||||||
{default}
|
{default}
|
||||||
{example}
|
{example}
|
||||||
|
|
||||||
{description}
|
|
||||||
`````
|
`````
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -25,15 +24,12 @@ 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.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 +53,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 +76,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):
|
||||||
|
|||||||
+86
-87
@@ -77,7 +77,7 @@
|
|||||||
];
|
];
|
||||||
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,7 +91,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 =
|
||||||
@@ -144,112 +144,111 @@
|
|||||||
password user2
|
password user2
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
"root/virus-email".text =
|
"root/virus-email".text = ''
|
||||||
# mail
|
From: User2 <user@example2.com>
|
||||||
''
|
Content-Type: multipart/mixed;
|
||||||
From: User2 <user@example2.com>
|
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
|
||||||
Content-Type: multipart/mixed;
|
Mime-Version: 1.0 (Mac OS X Mail 11.3 \(3445.6.18\))
|
||||||
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
|
Subject: Testy McTest
|
||||||
Mime-Version: 1.0 (Mac OS X Mail 11.3 \(3445.6.18\))
|
Message-Id: <94550DD9-1FF1-4ED1-9F09-8812FF2E59AA@example.com>
|
||||||
Subject: Testy McTest
|
Date: Sat, 12 May 2018 14:15:44 +0200
|
||||||
Message-Id: <94550DD9-1FF1-4ED1-9F09-8812FF2E59AA@example.com>
|
To: User1 <user1@example.com>
|
||||||
Date: Sat, 12 May 2018 14:15:44 +0200
|
X-Mailer: Apple Mail (2.3445.6.18)
|
||||||
To: User1 <user1@example.com>
|
|
||||||
X-Mailer: Apple Mail (2.3445.6.18)
|
|
||||||
|
|
||||||
|
|
||||||
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
|
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
|
||||||
Content-Transfer-Encoding: 7bit
|
Content-Transfer-Encoding: 7bit
|
||||||
Content-Type: text/plain;
|
Content-Type: text/plain;
|
||||||
charset=us-ascii
|
charset=us-ascii
|
||||||
|
|
||||||
Hello
|
Hello
|
||||||
|
|
||||||
I have attached a dangerous virus.
|
I have attached a dangerous virus.
|
||||||
|
|
||||||
Mfg.
|
Mfg.
|
||||||
User2
|
User2
|
||||||
|
|
||||||
|
|
||||||
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
|
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
|
||||||
Content-Disposition: attachment;
|
Content-Disposition: attachment;
|
||||||
filename=eicar.com.txt
|
filename=eicar.com.txt
|
||||||
Content-Type: text/plain;
|
Content-Type: text/plain;
|
||||||
x-unix-mode=0644;
|
x-unix-mode=0644;
|
||||||
name="eicar.com.txt"
|
name="eicar.com.txt"
|
||||||
Content-Transfer-Encoding: 7bit
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
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>
|
||||||
''
|
To: User1 <user1@example.com>
|
||||||
From: User <user@example2.com>
|
Cc:
|
||||||
To: User1 <user1@example.com>
|
Bcc:
|
||||||
Cc:
|
Subject: This is a test Email from user@example2.com to user1
|
||||||
Bcc:
|
Reply-To:
|
||||||
Subject: This is a test Email from user@example2.com to user1
|
|
||||||
Reply-To:
|
|
||||||
|
|
||||||
Hello User1,
|
Hello User1,
|
||||||
|
|
||||||
how are you doing today?
|
how are you doing today?
|
||||||
|
|
||||||
XOXO User1
|
XOXO User1
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
testScript =
|
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")
|
||||||
client.succeed("ls -la ~/ >&2")
|
client.succeed("ls -la ~/ >&2")
|
||||||
client.succeed("cat ~/.fetchmailrc >&2")
|
client.succeed("cat ~/.fetchmailrc >&2")
|
||||||
client.succeed("cat ~/.procmailrc >&2")
|
client.succeed("cat ~/.procmailrc >&2")
|
||||||
client.succeed("cat ~/.msmtprc >&2")
|
client.succeed("cat ~/.msmtprc >&2")
|
||||||
|
|
||||||
# fetchmail returns EXIT_CODE 1 when no new mail
|
# fetchmail returns EXIT_CODE 1 when no new mail
|
||||||
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2")
|
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2")
|
||||||
|
|
||||||
# Verify that mail can be sent and received before testing virus scanner
|
# Verify that mail can be sent and received before testing virus scanner
|
||||||
client.execute("rm ~/mail/*")
|
client.execute("rm ~/mail/*")
|
||||||
client.succeed("msmtp -a user2 user1@example.com < /etc/root/safe-email >&2")
|
client.succeed("msmtp -a user2 user1@example.com < /etc/root/safe-email >&2")
|
||||||
# give the mail server some time to process the mail
|
# give the mail server some time to process the mail
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
client.execute("rm ~/mail/*")
|
client.execute("rm ~/mail/*")
|
||||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||||
client.succeed("fetchmail --nosslcertck -v >&2")
|
client.succeed("fetchmail --nosslcertck -v >&2")
|
||||||
client.execute("rm ~/mail/*")
|
client.execute("rm ~/mail/*")
|
||||||
|
|
||||||
with subtest("virus scan file"):
|
with subtest("virus scan file"):
|
||||||
server.succeed(
|
server.succeed(
|
||||||
'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2'
|
'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2'
|
||||||
)
|
)
|
||||||
|
|
||||||
with subtest("virus scan email"):
|
with subtest("virus scan email"):
|
||||||
client.succeed(
|
client.succeed(
|
||||||
'set +o pipefail; msmtp -a user2 user1@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2'
|
'set +o pipefail; msmtp -a user2 user1@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2'
|
||||||
)
|
)
|
||||||
server.succeed("journalctl -u rspamd | grep -i eicar")
|
server.succeed("journalctl -u rspamd | grep -i eicar")
|
||||||
# give the mail server some time to process the mail
|
# give the mail server some time to process the mail
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
|
|
||||||
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")
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
+303
-363
@@ -26,10 +26,7 @@
|
|||||||
./lib/config.nix
|
./lib/config.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [ netcat ];
|
||||||
netcat
|
|
||||||
openssl
|
|
||||||
];
|
|
||||||
|
|
||||||
virtualisation.memorySize = 1024;
|
virtualisation.memorySize = 1024;
|
||||||
|
|
||||||
@@ -49,24 +46,10 @@
|
|||||||
"example2.com"
|
"example2.com"
|
||||||
];
|
];
|
||||||
rewriteMessageId = true;
|
rewriteMessageId = true;
|
||||||
dkim = {
|
dkimKeyBits = 1535;
|
||||||
defaults.keyLength = 1535;
|
|
||||||
domains."example2.com".selectors = {
|
|
||||||
"dkim-rsa" = {
|
|
||||||
# rsa 1535 bits via defaults
|
|
||||||
};
|
|
||||||
"dkim-ed25519" = {
|
|
||||||
keyType = "ed25519";
|
|
||||||
keyLength = null;
|
|
||||||
};
|
|
||||||
"dkim-file" = {
|
|
||||||
keyFile = "/run/rspamd/dkim-test.key";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
dmarcReporting.enable = true;
|
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,11 +64,11 @@
|
|||||||
};
|
};
|
||||||
"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"
|
"user1@example.com"
|
||||||
@@ -98,13 +81,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, ... }:
|
{ nodes, pkgs, ... }:
|
||||||
@@ -121,89 +104,80 @@
|
|||||||
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"
|
#!${pkgs.python3.interpreter}
|
||||||
# python
|
import imaplib
|
||||||
''
|
|
||||||
#!${pkgs.python3.interpreter}
|
|
||||||
import imaplib
|
|
||||||
|
|
||||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||||
imap.login('user1@example.com', 'user1')
|
imap.login('user1@example.com', 'user1')
|
||||||
imap.select()
|
imap.select()
|
||||||
status, [response] = imap.search(None, 'ALL')
|
status, [response] = imap.search(None, 'ALL')
|
||||||
msg_ids = response.decode("utf-8").split(' ')
|
msg_ids = response.decode("utf-8").split(' ')
|
||||||
print(msg_ids)
|
print(msg_ids)
|
||||||
assert status == 'OK'
|
assert status == 'OK'
|
||||||
assert len(msg_ids) == 1
|
assert len(msg_ids) == 1
|
||||||
|
|
||||||
imap.copy(','.join(msg_ids), 'Junk')
|
imap.copy(','.join(msg_ids), 'Junk')
|
||||||
for num in msg_ids:
|
for num in msg_ids:
|
||||||
imap.store(num, '+FLAGS', '\\Deleted')
|
imap.store(num, '+FLAGS', '\\Deleted')
|
||||||
imap.expunge()
|
imap.expunge()
|
||||||
|
|
||||||
imap.select('Junk')
|
imap.select('Junk')
|
||||||
status, [response] = imap.search(None, 'ALL')
|
status, [response] = imap.search(None, 'ALL')
|
||||||
msg_ids = response.decode("utf-8").split(' ')
|
msg_ids = response.decode("utf-8").split(' ')
|
||||||
print(msg_ids)
|
print(msg_ids)
|
||||||
assert status == 'OK'
|
assert status == 'OK'
|
||||||
assert len(msg_ids) == 1
|
assert len(msg_ids) == 1
|
||||||
|
|
||||||
imap.close()
|
imap.close()
|
||||||
'';
|
'';
|
||||||
test-imap-ham =
|
test-imap-ham = pkgs.writeScriptBin "imap-mark-ham" ''
|
||||||
pkgs.writeScriptBin "imap-mark-ham"
|
#!${pkgs.python3.interpreter}
|
||||||
# python
|
import imaplib
|
||||||
''
|
|
||||||
#!${pkgs.python3.interpreter}
|
|
||||||
import imaplib
|
|
||||||
|
|
||||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||||
imap.login('user1@example.com', 'user1')
|
imap.login('user1@example.com', 'user1')
|
||||||
imap.select('Junk')
|
imap.select('Junk')
|
||||||
status, [response] = imap.search(None, 'ALL')
|
status, [response] = imap.search(None, 'ALL')
|
||||||
msg_ids = response.decode("utf-8").split(' ')
|
msg_ids = response.decode("utf-8").split(' ')
|
||||||
print(msg_ids)
|
print(msg_ids)
|
||||||
assert status == 'OK'
|
assert status == 'OK'
|
||||||
assert len(msg_ids) == 1
|
assert len(msg_ids) == 1
|
||||||
|
|
||||||
imap.copy(','.join(msg_ids), 'INBOX')
|
imap.copy(','.join(msg_ids), 'INBOX')
|
||||||
for num in msg_ids:
|
for num in msg_ids:
|
||||||
imap.store(num, '+FLAGS', '\\Deleted')
|
imap.store(num, '+FLAGS', '\\Deleted')
|
||||||
imap.expunge()
|
imap.expunge()
|
||||||
|
|
||||||
imap.select('INBOX')
|
imap.select('INBOX')
|
||||||
status, [response] = imap.search(None, 'ALL')
|
status, [response] = imap.search(None, 'ALL')
|
||||||
msg_ids = response.decode("utf-8").split(' ')
|
msg_ids = response.decode("utf-8").split(' ')
|
||||||
print(msg_ids)
|
print(msg_ids)
|
||||||
assert status == 'OK'
|
assert status == 'OK'
|
||||||
assert len(msg_ids) == 1
|
assert len(msg_ids) == 1
|
||||||
|
|
||||||
imap.close()
|
imap.close()
|
||||||
'';
|
'';
|
||||||
search =
|
search = pkgs.writeScriptBin "search" ''
|
||||||
pkgs.writeScriptBin "search"
|
#!${pkgs.python3.interpreter}
|
||||||
# python
|
import imaplib
|
||||||
''
|
import sys
|
||||||
#!${pkgs.python3.interpreter}
|
|
||||||
import imaplib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
[_, mailbox, needle] = sys.argv
|
[_, mailbox, needle] = sys.argv
|
||||||
|
|
||||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||||
imap.login('user1@example.com', 'user1')
|
imap.login('user1@example.com', 'user1')
|
||||||
imap.select(mailbox)
|
imap.select(mailbox)
|
||||||
status, [response] = imap.search(None, 'BODY', repr(needle))
|
status, [response] = imap.search(None, 'BODY', repr(needle))
|
||||||
msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ]
|
msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ]
|
||||||
print(msg_ids)
|
print(msg_ids)
|
||||||
assert status == 'OK'
|
assert status == 'OK'
|
||||||
assert len(msg_ids) == 1
|
assert len(msg_ids) == 1
|
||||||
status, response = imap.fetch(msg_ids[0], '(RFC822)')
|
status, response = imap.fetch(msg_ids[0], '(RFC822)')
|
||||||
assert status == "OK"
|
assert status == "OK"
|
||||||
assert needle in repr(response)
|
assert needle in repr(response)
|
||||||
imap.close()
|
imap.close()
|
||||||
'';
|
'';
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
@@ -278,311 +252,277 @@
|
|||||||
password user1
|
password user1
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
"root/email1".text =
|
"root/email1".text = ''
|
||||||
# mail
|
Message-ID: <12345qwerty@host.local.network>
|
||||||
''
|
From: User2 <user2@example.com>
|
||||||
Message-ID: <12345qwerty@host.local.network>
|
To: User1 <user1@example.com>
|
||||||
From: User2 <user2@example.com>
|
Cc:
|
||||||
To: User1 <user1@example.com>
|
Bcc:
|
||||||
Cc:
|
Subject: This is a test Email from user2 to user1
|
||||||
Bcc:
|
Reply-To:
|
||||||
Subject: This is a test Email from user2 to user1
|
|
||||||
Reply-To:
|
|
||||||
|
|
||||||
Hello User1,
|
Hello User1,
|
||||||
|
|
||||||
how are you doing today?
|
how are you doing today?
|
||||||
'';
|
'';
|
||||||
"root/email2".text =
|
"root/email2".text = ''
|
||||||
# mail
|
Message-ID: <232323abc@host.local.network>
|
||||||
''
|
From: User <user@example2.com>
|
||||||
Message-ID: <232323abc@host.local.network>
|
To: User1 <user1@example.com>
|
||||||
From: User <user@example2.com>
|
Cc:
|
||||||
To: User1 <user1@example.com>
|
Bcc:
|
||||||
Cc:
|
Subject: This is a test Email from user@example2.com to user1
|
||||||
Bcc:
|
Reply-To:
|
||||||
Subject: This is a test Email from user@example2.com to user1
|
|
||||||
Reply-To:
|
|
||||||
|
|
||||||
Hello User1,
|
Hello User1,
|
||||||
|
|
||||||
how are you doing today? I have this exciting text for you, that helps fill
|
how are you doing today?
|
||||||
your quota.
|
|
||||||
|
|
||||||
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod
|
XOXO User1
|
||||||
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,
|
"root/email3".text = ''
|
||||||
no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit
|
Message-ID: <asdfghjkl42@host.local.network>
|
||||||
amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut
|
From: Postmaster <postmaster@example.com>
|
||||||
labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam
|
To: Chuck <chuck@example.com>
|
||||||
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata
|
Cc:
|
||||||
sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur
|
Bcc:
|
||||||
sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore
|
Subject: This is a test Email from postmaster@example.com to chuck
|
||||||
magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo
|
Reply-To:
|
||||||
dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est
|
|
||||||
Lorem ipsum dolor sit amet.
|
|
||||||
|
|
||||||
XOXO User1
|
Hello Chuck,
|
||||||
'';
|
|
||||||
"root/email3".text =
|
|
||||||
# mail
|
|
||||||
''
|
|
||||||
Message-ID: <asdfghjkl42@host.local.network>
|
|
||||||
From: Postmaster <postmaster@example.com>
|
|
||||||
To: Chuck <chuck@example.com>
|
|
||||||
Cc:
|
|
||||||
Bcc:
|
|
||||||
Subject: This is a test Email from postmaster@example.com to chuck
|
|
||||||
Reply-To:
|
|
||||||
|
|
||||||
Hello Chuck,
|
I think I may have misconfigured the mail server
|
||||||
|
XOXO Postmaster
|
||||||
|
'';
|
||||||
|
"root/email4".text = ''
|
||||||
|
Message-ID: <sdfsdf@host.local.network>
|
||||||
|
From: Single Alias <single-alias@example.com>
|
||||||
|
To: User1 <user1@example.com>
|
||||||
|
Cc:
|
||||||
|
Bcc:
|
||||||
|
Subject: This is a test Email from single-alias@example.com to user1
|
||||||
|
Reply-To:
|
||||||
|
|
||||||
I think I may have misconfigured the mail server
|
Hello User1,
|
||||||
XOXO Postmaster
|
|
||||||
'';
|
|
||||||
"root/email4".text =
|
|
||||||
# mail
|
|
||||||
''
|
|
||||||
Message-ID: <sdfsdf@host.local.network>
|
|
||||||
From: Single Alias <single-alias@example.com>
|
|
||||||
To: User1 <user1@example.com>
|
|
||||||
Cc:
|
|
||||||
Bcc:
|
|
||||||
Subject: This is a test Email from single-alias@example.com to user1
|
|
||||||
Reply-To:
|
|
||||||
|
|
||||||
Hello User1,
|
how are you doing today?
|
||||||
|
|
||||||
how are you doing today?
|
XOXO User1 aka Single Alias
|
||||||
|
'';
|
||||||
|
"root/email5".text = ''
|
||||||
|
Message-ID: <789asdf@host.local.network>
|
||||||
|
From: User2 <user2@example.com>
|
||||||
|
To: Multi Alias <multi-alias@example.com>
|
||||||
|
Cc:
|
||||||
|
Bcc:
|
||||||
|
Subject: This is a test Email from user2@example.com to multi-alias
|
||||||
|
Reply-To:
|
||||||
|
|
||||||
XOXO User1 aka Single Alias
|
Hello Multi Alias,
|
||||||
'';
|
|
||||||
"root/email5".text =
|
|
||||||
# mail
|
|
||||||
''
|
|
||||||
Message-ID: <789asdf@host.local.network>
|
|
||||||
From: User2 <user2@example.com>
|
|
||||||
To: Multi Alias <multi-alias@example.com>
|
|
||||||
Cc:
|
|
||||||
Bcc:
|
|
||||||
Subject: This is a test Email from user2@example.com to multi-alias
|
|
||||||
Reply-To:
|
|
||||||
|
|
||||||
Hello Multi Alias,
|
how are we doing today?
|
||||||
|
|
||||||
how are we doing today?
|
XOXO User1
|
||||||
|
'';
|
||||||
|
"root/email6".text = ''
|
||||||
|
Message-ID: <123457qwerty@host.local.network>
|
||||||
|
From: User2 <user2@example.com>
|
||||||
|
To: User1 <user1@example.com>
|
||||||
|
Cc:
|
||||||
|
Bcc:
|
||||||
|
Subject: This is a test Email from user2 to user1
|
||||||
|
Reply-To:
|
||||||
|
|
||||||
XOXO User1
|
Hello User1,
|
||||||
'';
|
|
||||||
"root/email6".text =
|
|
||||||
# mail
|
|
||||||
''
|
|
||||||
Message-ID: <123457qwerty@host.local.network>
|
|
||||||
From: User2 <user2@example.com>
|
|
||||||
To: User1 <user1@example.com>
|
|
||||||
Cc:
|
|
||||||
Bcc:
|
|
||||||
Subject: This is a test Email from user2 to user1
|
|
||||||
Reply-To:
|
|
||||||
|
|
||||||
Hello User1,
|
this email contains the needle:
|
||||||
|
576a4565b70f5a4c1a0925cabdb587a6
|
||||||
|
'';
|
||||||
|
"root/email7".text = ''
|
||||||
|
Message-ID: <1234578qwerty@host.local.network>
|
||||||
|
From: User2 <user2@example.com>
|
||||||
|
To: User1 <user1@example.com>
|
||||||
|
Cc:
|
||||||
|
Bcc:
|
||||||
|
Subject: This is a test Email from user2 to user1
|
||||||
|
Reply-To:
|
||||||
|
|
||||||
this email contains the needle:
|
Hello User1,
|
||||||
576a4565b70f5a4c1a0925cabdb587a6
|
|
||||||
'';
|
|
||||||
"root/email7".text =
|
|
||||||
# mail
|
|
||||||
''
|
|
||||||
Message-ID: <1234578qwerty@host.local.network>
|
|
||||||
From: User2 <user2@example.com>
|
|
||||||
To: User1 <user1@example.com>
|
|
||||||
Cc:
|
|
||||||
Bcc:
|
|
||||||
Subject: This is a test Email from user2 to user1
|
|
||||||
Reply-To:
|
|
||||||
|
|
||||||
Hello User1,
|
this email does not contain the needle :(
|
||||||
|
'';
|
||||||
this email does not contain the needle :(
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
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(
|
||||||
|
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
||||||
|
)
|
||||||
|
|
||||||
server.succeed("rspamadm dkim_keygen > /run/rspamd/dkim-test.key")
|
client.execute("cp -p /etc/root/.* ~/")
|
||||||
server.succeed("chown rspamd: /run/rspamd/dkim-test.key")
|
client.succeed("mkdir -p ~/mail")
|
||||||
|
client.succeed("ls -la ~/ >&2")
|
||||||
|
client.succeed("cat ~/.fetchmailrc >&2")
|
||||||
|
client.succeed("cat ~/.procmailrc >&2")
|
||||||
|
client.succeed("cat ~/.msmtprc >&2")
|
||||||
|
|
||||||
client.execute("cp -p /etc/root/.* ~/")
|
with subtest("imap retrieving mail"):
|
||||||
client.succeed("mkdir -p ~/mail")
|
# fetchmail returns EXIT_CODE 1 when no new mail
|
||||||
client.succeed("ls -la ~/ >&2")
|
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2")
|
||||||
client.succeed("cat ~/.fetchmailrc >&2")
|
|
||||||
client.succeed("cat ~/.procmailrc >&2")
|
|
||||||
client.succeed("cat ~/.msmtprc >&2")
|
|
||||||
|
|
||||||
with subtest("imap retrieving mail"):
|
with subtest("submission port send mail"):
|
||||||
# fetchmail returns EXIT_CODE 1 when no new mail
|
# send email from user2 to user1
|
||||||
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2")
|
client.succeed(
|
||||||
|
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2"
|
||||||
|
)
|
||||||
|
# give the mail server some time to process the mail
|
||||||
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
|
|
||||||
with subtest("submission port send mail"):
|
with subtest("imap retrieving mail 2"):
|
||||||
# send email from user2 to user1
|
client.execute("rm ~/mail/*")
|
||||||
client.succeed(
|
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2"
|
client.succeed("fetchmail --nosslcertck -v >&2")
|
||||||
)
|
|
||||||
# give the mail server some time to process the mail
|
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
|
||||||
|
|
||||||
with subtest("imap retrieving mail 2"):
|
with subtest("remove sensitive information on submission port"):
|
||||||
client.execute("rm ~/mail/*")
|
client.succeed("cat ~/mail/* >&2")
|
||||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
## make sure our IP is _not_ in the email header
|
||||||
client.succeed("fetchmail --nosslcertck -v >&2")
|
client.fail("grep-ip ~/mail/*")
|
||||||
|
client.succeed("check-mail-id ~/mail/*")
|
||||||
|
|
||||||
with subtest("remove sensitive information on submission port"):
|
with subtest("have correct fqdn as sender"):
|
||||||
client.succeed("cat ~/mail/* >&2")
|
client.succeed("grep 'Received: from mail.example.com' ~/mail/*")
|
||||||
## make sure our IP is _not_ in the email header
|
|
||||||
client.fail("grep-ip ~/mail/*")
|
|
||||||
client.succeed("check-mail-id ~/mail/*")
|
|
||||||
|
|
||||||
with subtest("have correct fqdn as sender"):
|
with subtest("dkim has user-specified size"):
|
||||||
client.succeed("grep 'Received: from mail.example.com' ~/mail/*")
|
server.succeed(
|
||||||
|
"openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'"
|
||||||
|
)
|
||||||
|
|
||||||
with subtest("dkim has user-specified size"):
|
with subtest("dkim singing, multiple domains"):
|
||||||
server.succeed(
|
client.execute("rm ~/mail/*")
|
||||||
"openssl rsa -in /var/dkim/example2.com.dkim-rsa.key -text -noout | grep 'Private-Key: (1535 bit'"
|
# send email from user2 to user1
|
||||||
)
|
client.succeed(
|
||||||
|
"msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email2 >&2"
|
||||||
|
)
|
||||||
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
|
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||||
|
client.succeed("fetchmail --nosslcertck -v")
|
||||||
|
client.succeed("cat ~/mail/* >&2")
|
||||||
|
# make sure it is dkim signed
|
||||||
|
client.succeed("grep DKIM-Signature: ~/mail/*")
|
||||||
|
|
||||||
with subtest("dkim signing, multiple domains"):
|
with subtest("aliases"):
|
||||||
client.execute("rm ~/mail/*")
|
client.execute("rm ~/mail/*")
|
||||||
# send email from user2 to user1
|
# send email from chuck to postmaster
|
||||||
client.succeed(
|
client.succeed(
|
||||||
"msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email2 >&2"
|
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster@example.com < /etc/root/email2 >&2"
|
||||||
)
|
)
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
# 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")
|
||||||
client.succeed("cat ~/mail/* >&2")
|
|
||||||
# make sure it is dkim signed
|
|
||||||
client.succeed("grep 's=dkim-rsa' ~/mail/*")
|
|
||||||
client.succeed("grep 's=dkim-ed25519' ~/mail/*")
|
|
||||||
client.succeed("grep 's=dkim-file' ~/mail/*")
|
|
||||||
|
|
||||||
with subtest("aliases"):
|
with subtest("catchAlls"):
|
||||||
client.execute("rm ~/mail/*")
|
client.execute("rm ~/mail/*")
|
||||||
# send email from chuck to postmaster
|
# send email from chuck to non exsitent account
|
||||||
client.succeed(
|
client.succeed(
|
||||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster@example.com < /etc/root/email2 >&2"
|
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol@example.com < /etc/root/email2 >&2"
|
||||||
)
|
)
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
# 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"):
|
client.execute("rm ~/mail/*")
|
||||||
client.execute("rm ~/mail/*")
|
# send email from user1 to chuck
|
||||||
# send email from chuck to non-existent account
|
client.succeed(
|
||||||
client.succeed(
|
"msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck@example.com < /etc/root/email2 >&2"
|
||||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol@example.com < /etc/root/email2 >&2"
|
)
|
||||||
)
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
# fetchmail returns EXIT_CODE 1 when no new mail
|
||||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
# if this succeeds, it means that user1 recieved the mail that was intended for chuck.
|
||||||
client.succeed("fetchmail --nosslcertck -v")
|
client.fail("fetchmail --nosslcertck -v")
|
||||||
|
|
||||||
client.execute("rm ~/mail/*")
|
with subtest("extraVirtualAliases"):
|
||||||
# send email from user1 to chuck
|
client.execute("rm ~/mail/*")
|
||||||
client.succeed(
|
# send email from single-alias to user1
|
||||||
"msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck@example.com < /etc/root/email2 >&2"
|
client.succeed(
|
||||||
)
|
"msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email4 >&2"
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
)
|
||||||
# fetchmail returns EXIT_CODE 1 when no new mail
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
# if this succeeds, it means that user1 received the mail that was intended for chuck.
|
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||||
client.fail("fetchmail --nosslcertck -v")
|
client.succeed("fetchmail --nosslcertck -v")
|
||||||
|
|
||||||
with subtest("Test sending from alias address (mailserver.aliases)"):
|
client.execute("rm ~/mail/*")
|
||||||
client.execute("rm ~/mail/*")
|
# send email from user1 to multi-alias (user{1,2}@example.com)
|
||||||
# send email from single-alias to user1
|
client.succeed(
|
||||||
client.succeed(
|
"msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias@example.com < /etc/root/email5 >&2"
|
||||||
"msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email4 >&2"
|
)
|
||||||
)
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
# 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")
|
|
||||||
|
|
||||||
client.execute("rm ~/mail/*")
|
with subtest("quota"):
|
||||||
# send email from user1 to multi-alias (user{1,2}@example.com)
|
client.execute("rm ~/mail/*")
|
||||||
client.succeed(
|
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
|
||||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias@example.com < /etc/root/email5 >&2"
|
|
||||||
)
|
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
|
||||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
|
||||||
client.succeed("fetchmail --nosslcertck -v")
|
|
||||||
|
|
||||||
with subtest("quota"):
|
client.succeed(
|
||||||
client.execute("rm ~/mail/*")
|
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2"
|
||||||
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
|
)
|
||||||
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
|
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||||
|
client.fail("fetchmail --nosslcertck -v")
|
||||||
|
|
||||||
server.log(server.succeed("doveadm quota get -u lowquota@example.com"))
|
with subtest("imap sieve junk trainer"):
|
||||||
|
# send email from user2 to user1
|
||||||
|
client.succeed(
|
||||||
|
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2"
|
||||||
|
)
|
||||||
|
# give the mail server some time to process the mail
|
||||||
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
|
|
||||||
client.succeed(
|
client.succeed("imap-mark-spam >&2")
|
||||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2"
|
server.wait_until_succeeds("journalctl -u dovecot -u dovecot2 | grep -i rspamd-learn-spam.sh >&2")
|
||||||
)
|
client.succeed("imap-mark-ham >&2")
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
server.wait_until_succeeds("journalctl -u dovecot -u dovecot2 | grep -i rspamd-learn-ham.sh >&2")
|
||||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
|
||||||
client.fail("fetchmail --nosslcertck -v")
|
|
||||||
|
|
||||||
with subtest("imap sieve junk trainer"):
|
with subtest("full text search and indexation"):
|
||||||
# send email from user2 to user1
|
# send 2 email from user2 to user1
|
||||||
client.succeed(
|
client.succeed(
|
||||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2"
|
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2"
|
||||||
)
|
)
|
||||||
# give the mail server some time to process the mail
|
client.succeed(
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email7 >&2"
|
||||||
|
)
|
||||||
|
# give the mail server some time to process the mail
|
||||||
|
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||||
|
|
||||||
client.succeed("imap-mark-spam >&2")
|
# should find exactly one email containing this
|
||||||
server.wait_until_succeeds("journalctl -u dovecot | grep -i rspamd-learn-spam.sh >&2")
|
client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2")
|
||||||
client.succeed("imap-mark-ham >&2")
|
# should fail because this folder is not indexed
|
||||||
server.wait_until_succeeds("journalctl -u dovecot | grep -i rspamd-learn-ham.sh >&2")
|
client.fail("search Junk a >&2")
|
||||||
|
# check that search really goes through the indexer
|
||||||
|
server.succeed("journalctl -u dovecot -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2")
|
||||||
|
# check that Junk is not indexed
|
||||||
|
server.fail("journalctl -u dovecot -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
|
||||||
|
|
||||||
with subtest("full text search and indexation"):
|
with subtest("dmarc reporting"):
|
||||||
# send 2 email from user2 to user1
|
server.systemctl("start rspamd-dmarc-reporter.service")
|
||||||
client.succeed(
|
|
||||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2"
|
|
||||||
)
|
|
||||||
client.succeed(
|
|
||||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email7 >&2"
|
|
||||||
)
|
|
||||||
# give the mail server some time to process the mail
|
|
||||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
|
||||||
|
|
||||||
# should find exactly one email containing this
|
with subtest("no warnings or errors"):
|
||||||
client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2")
|
server.fail("journalctl -u postfix | grep -i error >&2")
|
||||||
# should fail because this folder is not indexed
|
server.fail("journalctl -u postfix | grep -i warning >&2")
|
||||||
client.fail("search Junk a >&2")
|
server.fail("journalctl -u dovecot -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
|
||||||
# check that search really goes through the indexer
|
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
|
||||||
server.succeed("journalctl -u dovecot | grep 'fts-flatcurve(INBOX): Query ' >&2")
|
server.fail(
|
||||||
# check that Junk is not indexed
|
"journalctl -u dovecot -u dovecot2 | \
|
||||||
server.fail("journalctl -u dovecot | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
|
grep -v 'Expunged message reappeared, giving a new UID' | \
|
||||||
|
grep -v 'Time moved forwards' | \
|
||||||
with subtest("dmarc reporting"):
|
grep -i warning >&2"
|
||||||
server.systemctl("start rspamd-dmarc-reporter.service")
|
)
|
||||||
|
'';
|
||||||
with subtest("no warnings or errors"):
|
|
||||||
server.fail("journalctl -u postfix | grep -i error >&2")
|
|
||||||
server.fail("journalctl -u postfix | grep -i warning >&2")
|
|
||||||
server.fail("journalctl -u dovecot | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
|
|
||||||
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
|
|
||||||
server.fail(
|
|
||||||
"journalctl -u dovecot | \
|
|
||||||
grep -v 'Expunged message reappeared, giving a new UID' | \
|
|
||||||
grep -v 'Time moved forwards' | \
|
|
||||||
grep -i warning >&2"
|
|
||||||
)
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-105
@@ -38,22 +38,10 @@ let
|
|||||||
inherit password;
|
inherit password;
|
||||||
}
|
}
|
||||||
''
|
''
|
||||||
mkpasswd -s <<<"$password" > $out
|
mkpasswd -sm bcrypt <<<"$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
|
||||||
{
|
{
|
||||||
@@ -61,7 +49,7 @@ in
|
|||||||
|
|
||||||
nodes = {
|
nodes = {
|
||||||
machine =
|
machine =
|
||||||
{ pkgs, lib, ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
./../default.nix
|
./../default.nix
|
||||||
@@ -81,17 +69,6 @@ in
|
|||||||
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";
|
||||||
@@ -101,7 +78,7 @@ in
|
|||||||
];
|
];
|
||||||
localDnsResolver = false;
|
localDnsResolver = false;
|
||||||
|
|
||||||
accounts = {
|
loginAccounts = {
|
||||||
"user1@example.com" = {
|
"user1@example.com" = {
|
||||||
hashedPasswordFile = hashedPasswordFile;
|
hashedPasswordFile = hashedPasswordFile;
|
||||||
};
|
};
|
||||||
@@ -109,13 +86,6 @@ in
|
|||||||
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";
|
||||||
sendOnly = true;
|
sendOnly = true;
|
||||||
@@ -127,12 +97,9 @@ in
|
|||||||
"user2@example.com" = "user1@example.com";
|
"user2@example.com" = "user1@example.com";
|
||||||
};
|
};
|
||||||
|
|
||||||
storage = {
|
vmailGroupName = "vmail";
|
||||||
gid = 5000;
|
vmailUID = 5000;
|
||||||
group = "vmail";
|
indexDir = "/var/lib/dovecot/indices";
|
||||||
};
|
|
||||||
|
|
||||||
indexDir = "/var/cache/dovecot/fts";
|
|
||||||
|
|
||||||
enableImap = false;
|
enableImap = false;
|
||||||
};
|
};
|
||||||
@@ -143,13 +110,9 @@ in
|
|||||||
nodes,
|
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"):
|
||||||
@@ -236,9 +199,8 @@ in
|
|||||||
|
|
||||||
with subtest("Check dovecot maildir and index locations"):
|
with subtest("Check dovecot maildir and index locations"):
|
||||||
# If these paths change we need a migration
|
# 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 home user1@example.com | grep ${nodes.machine.mailserver.mailDirectory}/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 user1@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/example.com/user1'")
|
||||||
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)
|
||||||
@@ -255,65 +217,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)
|
||||||
|
|||||||
+61
-209
@@ -1,37 +1,27 @@
|
|||||||
{
|
|
||||||
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, lib, ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
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} $@
|
||||||
@@ -63,70 +53,36 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
declarativeContents."dc=example" =
|
declarativeContents."dc=example" = ''
|
||||||
#ldif
|
dn: dc=example
|
||||||
''
|
objectClass: domain
|
||||||
dn: dc=example
|
dc: example
|
||||||
objectClass: domain
|
|
||||||
dc: example
|
|
||||||
|
|
||||||
dn: cn=mail,dc=example
|
dn: cn=mail,dc=example
|
||||||
objectClass: organizationalRole
|
objectClass: organizationalRole
|
||||||
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
|
cn: alice
|
||||||
uid: alice
|
sn: Foo
|
||||||
sn: Foo
|
mail: alice@example.com
|
||||||
mail: alice@example.com
|
userPassword: ${alicePassword}
|
||||||
# testalice
|
|
||||||
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
|
cn: bob
|
||||||
objectClass: posixAccount
|
sn: Bar
|
||||||
uid: bob
|
mail: bob@example.com
|
||||||
uidNumber: 9999
|
userPassword: ${bobPassword}
|
||||||
gidNumber: 9999
|
'';
|
||||||
sn: Bar
|
|
||||||
mail: bob@example.com
|
|
||||||
homeDirectory: /home/bob
|
|
||||||
# testbob
|
|
||||||
userPassword: {SSHA}qqUveZGZrDrjYFnREXLDZc//y89RppVN
|
|
||||||
|
|
||||||
dn: cn=carol,ou=users,dc=example
|
|
||||||
entryUUID: 41240499-27e2-4fa2-be4f-4113a77661b1
|
|
||||||
objectClass: inetOrgPerson
|
|
||||||
uid: carol
|
|
||||||
sn: Baz
|
|
||||||
mail: carol@example.com
|
|
||||||
# testcarol
|
|
||||||
userPassword: {SSHA}69HOuP+OPWE+3+tDucFZxzXDC7p4e3ML
|
|
||||||
|
|
||||||
dn: cn=frank,ou=users,dc=example
|
|
||||||
entryUUID: ca16f594-f6b2-418f-87d3-0d02d746461f
|
|
||||||
objectClass: inetOrgPerson
|
|
||||||
uid: frank
|
|
||||||
sn: Moo
|
|
||||||
mail: frank@example.com
|
|
||||||
# testfrank
|
|
||||||
userPassword: {SSHA}xqtMl8/uJ6HEFWDzLYpAE+Wq7FvKrtkm
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.dovecot.serviceConfig = {
|
|
||||||
CacheDirectory = "dovecot";
|
|
||||||
StateDirectory = "dovecot";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mailserver = {
|
mailserver = {
|
||||||
@@ -134,26 +90,7 @@ in
|
|||||||
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/lib/dovecot/indices";
|
||||||
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,52 +101,18 @@ 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
|
|
||||||
''
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -218,7 +121,6 @@ in
|
|||||||
nodes,
|
nodes,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# python
|
|
||||||
''
|
''
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
@@ -226,21 +128,13 @@ in
|
|||||||
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,28 +143,19 @@ 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 via explicit TLS"):
|
||||||
machine.fail(" ".join([
|
machine.fail(" ".join([
|
||||||
@@ -278,16 +163,16 @@ in
|
|||||||
"--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 via implicit TLS"):
|
||||||
machine.succeed(" ".join([
|
machine.succeed(" ".join([
|
||||||
@@ -295,9 +180,9 @@ in
|
|||||||
"--smtp-port 465",
|
"--smtp-port 465",
|
||||||
"--smtp-ssl",
|
"--smtp-ssl",
|
||||||
"--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}')",
|
||||||
@@ -311,9 +196,9 @@ in
|
|||||||
"--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}')",
|
||||||
@@ -327,7 +212,7 @@ in
|
|||||||
"--smtp-port 465",
|
"--smtp-port 465",
|
||||||
"--smtp-ssl",
|
"--smtp-ssl",
|
||||||
"--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 +221,11 @@ 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"):
|
with subtest("Check dovecot mail and index locations"):
|
||||||
# carol@example.com is routed to the local user account
|
# If these paths change we need a migration
|
||||||
machine.succeed(" ".join([
|
machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.mailDirectory}/ldap/bob@example.com")
|
||||||
"mail-check send-and-read",
|
machine.succeed("doveadm user -f mail bob@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/ldap/bob@example.com'")
|
||||||
"--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}'")
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIBizCCATGgAwIBAgIUN4ncJfMVIQSSurMkdE73x4aefTMwCgYIKoZIzj0EAwIw
|
|
||||||
GzEZMBcGA1UEAwwQdGVzdC5sb2NhbGRvbWFpbjAeFw0yNTEwMTgyMTQ4MTNaFw0z
|
|
||||||
NTEwMTYyMTQ4MTNaMBsxGTAXBgNVBAMMEHRlc3QubG9jYWxkb21haW4wWTATBgcq
|
|
||||||
hkjOPQIBBggqhkjOPQMBBwNCAARCJUj4j7eC/7Xso3REUscqHlWPvW9zvl5I6TIy
|
|
||||||
zEXFsWxM0QxMuNW4oXE56UiCyJklcpk0JfQUGat+kKQqSUJyo1MwUTAdBgNVHQ4E
|
|
||||||
FgQUW3CnmBf3n/Y30vfj3ERsIQnXu9QwHwYDVR0jBBgwFoAUW3CnmBf3n/Y30vfj
|
|
||||||
3ERsIQnXu9QwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAhwAi
|
|
||||||
K4xdr8KxD5xRvvzShheh48i8X7NtBIQ3bd01Jx4CIG/kYTDK5nDZri7UYOMsgz2l
|
|
||||||
iWss56p2dGWTL7LrBHgM
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -10,17 +10,6 @@
|
|||||||
# Keep testing submission with explicit TLS
|
# Keep testing submission with explicit TLS
|
||||||
mailserver.enableSubmission = true;
|
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
|
# Enable second CPU core
|
||||||
virtualisation.cores = lib.mkDefault 2;
|
virtualisation.cores = lib.mkDefault 2;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
-----BEGIN EC PRIVATE KEY-----
|
|
||||||
MHcCAQEEIObLW92AqkWunJXowVR2Z5/+yVPBaFHnEedDk5WJxk/BoAoGCCqGSM49
|
|
||||||
AwEHoUQDQgAEQiVI+I+3gv+17KN0RFLHKh5Vj71vc75eSOkyMsxFxbFsTNEMTLjV
|
|
||||||
uKFxOelIgsiZJXKZNCX0FBmrfpCkKklCcg==
|
|
||||||
-----END EC PRIVATE KEY-----
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
if address :is "from" "user1@example.com" {
|
|
||||||
redirect "user1@example.com";
|
|
||||||
}
|
|
||||||
+22
-20
@@ -15,7 +15,7 @@ let
|
|||||||
inherit password;
|
inherit password;
|
||||||
}
|
}
|
||||||
''
|
''
|
||||||
mkpasswd -s <<<"$password" > $out
|
mkpasswd -sm bcrypt <<<"$password" > $out
|
||||||
'';
|
'';
|
||||||
|
|
||||||
password = pkgs.writeText "password" "password";
|
password = pkgs.writeText "password" "password";
|
||||||
@@ -35,7 +35,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";
|
||||||
};
|
};
|
||||||
@@ -90,26 +90,28 @@ in
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
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 via explicit TLS
|
||||||
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 via implicit TLS 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 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"
|
||||||
)
|
)
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user