1 Commits

Author SHA1 Message Date
Jakub Skokan
284a1e4041 Allow TLSv1 for compatibility with older devices 2025-05-25 21:06:21 +02:00
39 changed files with 1716 additions and 2921 deletions

View File

@@ -1,21 +1,22 @@
{ nixpkgs, pulls, ... }: { nixpkgs, pulls, ... }:
let let
pkgs = import nixpkgs { }; pkgs = import nixpkgs {};
prs = builtins.fromJSON (builtins.readFile pulls); prs = builtins.fromJSON (builtins.readFile pulls);
prJobsets = pkgs.lib.mapAttrs (num: info: { prJobsets = pkgs.lib.mapAttrs (num: info:
enabled = 1; { enabled = 1;
hidden = false; hidden = false;
description = "PR ${num}: ${info.title}"; description = "PR ${num}: ${info.title}";
checkinterval = 300; checkinterval = 300;
schedulingshares = 20; schedulingshares = 20;
enableemail = false; enableemail = false;
emailoverride = ""; emailoverride = "";
keepnr = 1; keepnr = 1;
type = 1; type = 1;
flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head"; flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head";
}) prs; }
) prs;
mkFlakeJobset = branch: { mkFlakeJobset = branch: {
description = "Build ${branch} branch of Simple NixOS MailServer"; description = "Build ${branch} branch of Simple NixOS MailServer";
checkinterval = 300; checkinterval = 300;
@@ -31,8 +32,8 @@ let
desc = prJobsets // { desc = prJobsets // {
"master" = mkFlakeJobset "master"; "master" = mkFlakeJobset "master";
"nixos-24.11" = mkFlakeJobset "nixos-24.11";
"nixos-25.05" = mkFlakeJobset "nixos-25.05"; "nixos-25.05" = mkFlakeJobset "nixos-25.05";
"nixos-25.11" = mkFlakeJobset "nixos-25.11";
}; };
log = { log = {
@@ -40,14 +41,13 @@ let
jobsets = desc; jobsets = desc;
}; };
in in {
{ jobsets = pkgs.runCommand "spec-jobsets.json" {} ''
jobsets = pkgs.runCommand "spec-jobsets.json" { } '' cat >$out <<EOF
cat >$out <<'EOF'
${builtins.toJSON desc} ${builtins.toJSON desc}
EOF EOF
# This is to get nice .jobsets build logs on Hydra # This is to get nice .jobsets build logs on Hydra
cat >tmp <<'EOF' cat >tmp <<EOF
${builtins.toJSON log} ${builtins.toJSON log}
EOF EOF
${pkgs.jq}/bin/jq . tmp ${pkgs.jq}/bin/jq . tmp

View File

@@ -5,18 +5,17 @@
version: 2 version: 2
build: build:
os: ubuntu-24.04 os: ubuntu-22.04
tools: tools:
python: "3" python: "3"
apt_packages: apt_packages:
- curl - nix
- proot - proot
jobs: jobs:
pre_install: pre_install:
- curl -L https://github.com/DavHau/nix-portable/releases/latest/download/nix-portable-$(uname -m) > ./nix-portable - mkdir -p ~/.nix ~/.config/nix
- chmod +x ./nix-portable - echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf
- ./nix-portable nix build --print-build-logs .#optionsDoc - proot -b ~/.nix:/nix /bin/sh -c "nix build -L .#optionsDoc && cp -v result docs/options.md"
- ./nix-portable nix store cat $(readlink result) > docs/options.md
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py

View File

@@ -8,14 +8,14 @@
For each NixOS release, we publish a branch. You then have to use the For each NixOS release, we publish a branch. You then have to use the
SNM branch corresponding to your NixOS version. SNM branch corresponding to your NixOS version.
* For NixOS 25.11
* 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-25.11/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-25.11/release-notes.html#nixos-25-11)
* For NixOS 25.05 * For NixOS 25.05
* Use the [SNM branch `nixos-25.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/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/) * [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) * [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/release-notes.html#nixos-25-05)
* For NixOS 24.11
* Use the [SNM branch `nixos-24.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-24.11)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/release-notes.html#nixos-24-11)
* For NixOS unstable * For NixOS unstable
* Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master) * 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/)
@@ -29,8 +29,6 @@ SNM branch corresponding to your NixOS version.
* [x] Submission TLS on port 465 * [x] Submission TLS on port 465
* [x] Submission StartTLS on port 587 * [x] Submission StartTLS on port 587
* [x] LMTP with Dovecot * [x] LMTP with Dovecot
* [x] DANE and MTA-STS validation
* [x] SMTP TLS Reports ([RFC 8460](https://www.rfc-editor.org/rfc/rfc8460))
* Dovecot * Dovecot
* [x] Maildir folders * [x] Maildir folders
* [x] IMAP with TLS on port 993 * [x] IMAP with TLS on port 993
@@ -57,8 +55,6 @@ SNM branch corresponding to your NixOS version.
* User Aliases * User Aliases
* [x] Regular aliases * [x] Regular aliases
* [x] Catch all aliases * [x] Catch all aliases
* Improve the Forwarding Experience
* [x] [Sender Rewriting Scheme](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme)
### In the future ### In the future
@@ -71,6 +67,7 @@ SNM branch corresponding to your NixOS version.
* [ ] Allow passing DKIM signing keys * [ ] Allow passing DKIM signing keys
* Improve the Forwarding Experience * Improve the Forwarding Experience
* [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html) * [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html)
* [ ] Support [SRS](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme) with [postsrsd](https://github.com/roehling/postsrsd)
* User management * User management
* [ ] Allow local and LDAP user to coexist * [ ] Allow local and LDAP user to coexist
* OpenID Connect * OpenID Connect

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
Advanced Configurations
=======================
Congratulations on completing the `Setup Guide <setup-guide.html>`_!
If you're an experienced mailserver admin, then you probably know what you want
to do next. Our How-to guides (accessible in the navigation sidebar)
might help you accomplish your goals. If not, consider contributing a guide!
If this is your first mailserver, consider the following:
- Set up `backups <backup-guide.html>`_.
- Enable `DMARC reporting <options.html#mailserver-dmarcreporting>`_ to be a
good citizen in the mail ecosystem.

View File

@@ -14,13 +14,6 @@ forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them
back (or whatever you specified as ``vmailUserName``, and back (or whatever you specified as ``vmailUserName``, and
``vmailGoupName``). ``vmailGoupName``).
If you enabled ``enableManageSieve`` then you also may want to backup
``/var/sieve`` or whatever you have specified as ``sieveDirectory``.
The same considerations regarding file ownership apply as for the
Maildir.
To backup spam and ham training data, backup ``/var/lib/redis-rspamd``.
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever
you specified as ``dkimKeyDirectory``). If you should lose those dont you specified as ``dkimKeyDirectory``). If you should lose those dont
worry, new ones will be created on the fly. But you will need to repeat worry, new ones will be created on the fly. But you will need to repeat

View File

@@ -64,44 +64,3 @@ To build the documentation, you need to enable `Nix Flakes
$ nix build .#documentation $ nix build .#documentation
$ xdg-open result/index.html $ xdg-open result/index.html
Manual migrations
-----------------
We need to take great care around providing a migration story around breaking
changes. If manual intervention becomes necessary we provide the `stateVersion`
option to notify the user that they need to complete a migration before
they can deploy an update.
If that is the case for your change, find the highest `stateVersion` that is
being asserted on in `mail-server/assertions.nix`. Then pick the next number
and add a new assertion, write a good summary describing the issue and what
remediation steps are necessary. Finally reference the URL to the specific
section on the migration page in the documentation.
.. code-block:: nix
{
assertions = [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 1;
message = ''
Problem: The home directory for the foobar service is snafu.
Remediation:
- Stop the `foobar.service`
- Rename `/var/lib/foobaz` to `/var/lib/foobar`
- Increase the `mailserver.stateVersion` to 1.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#specific-anchor-here for further details.
'';
}
];
}
The setup guide should always reference the latest `stateVersion`, since we
don't require any migration steps for new setups.
The migration documentation should paint a more complete picture about the steps
that need to be carried out and why this has become necessary. Make sure to
reference the correct anchor in the URL you put into the assertion message.

View File

@@ -14,31 +14,23 @@ Welcome to NixOS Mailserver's documentation!
:maxdepth: 2 :maxdepth: 2
setup-guide setup-guide
advanced-configurations
howto-develop howto-develop
faq faq
release-notes release-notes
options options
migrations
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
:caption: Features
fts
ldap
srs
.. toctree::
:maxdepth: 0
:caption: How-to :caption: How-to
backup-guide backup-guide
add-radicale add-radicale
add-roundcube add-roundcube
rspamd-tuning rspamd-tuning
fts
flakes flakes
autodiscovery autodiscovery
ldap
Indices and tables Indices and tables
================== ==================

View File

@@ -1,117 +0,0 @@
Migrations
==========
With mail server configuration best practices changing over time we might need
to make changes that require you to complete manual migration steps before you
can deploy a new version of NixOS mailserver.
The initial `mailserver.stateVersion` value should be copied from the setup
guide that you used to initially set up your mail server. If in doubt you can
always initialize it at `1` and walk through all assertions, that might apply
to your setup.
NixOS 25.11
-----------
#3 Dovecot mail directory migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The way the Dovecot home directory for login accounts were previously set up
resulted in shared home directories for all those users. This is not a
supported Dovecot configuration.
To resolve this we migrated the home directory into the individual
`domain/localpart` subdirectory below the `mailserver.mailDirectory`.
But since this now overlaps with the location of the Maildir, it must be
migrated into the `mail/` directory below the home directory.
And while the LDAP home directory is not affected we use this migration to
keep the Maildir configurations of LDAP users in sync with those of local
accounts.
This is a big step forward, since we can now more cleanly colocate other
data directories, like sieve in the home directory, which in turn simplifies
backups.
This migration is required for every configuration.
For remediating this issue the following steps are required:
1. Copy the `migration script <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/migrations/nixos-mailserver-migration-03.py>`_ script to your mailserver
and make it executable:
.. code-block:: bash
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/master/migrations/nixos-mailserver-migration-03.py
chmod +x nixos-mailserver-migration-03.py
2. Stop the ``dovecot2.service``.
.. code-block:: bash
systemctl stop dovecot2.service
3. Create a backup or snapshot of your ``mailserver.mailDirectory``, so you can restore
should anything go wrong.
4. Run the migration script under your virtual mail user with the following arguments:
- ``--layout default`` unless ``useFSLayout`` is enabled, then ``--layout folder``
- The value of ``mailserver.mailDirectory``, which defaults to ``/var/vmail``
The script should be run under the user who owns the ``mailDirectory``.
If run as root it will try to switch into the appropriate user by itself.
The script will not modify your data unless called with ``--execute``.
Example:
.. code-block:: bash
./nixos-mailserver-migration-03.py --layout default /var/vmail
5. Review the commands. They should be
- create a ``mail`` directory for each accounnt,
- move maildir contents from the parent directory into it,
- suggest removal of files that do not belong to the maildir
- their removal is not mandatory and the script **will not** remove them when called with ``--execute``
- review these items carefully if you want to remove them yourself
- remove obsolete files from the old home directory location
6. Rerun the command with ``--execute`` or run the commands manually.
7. Update the ``mailserver.stateVersion`` to ``3``.
#2 Dovecot LDAP home directory migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The Dovecot configuration for LDAP home directories previously did not respect
the ``mailserver.mailDirectory`` setting.
This means that home directories were unconditionally located at
``/var/vmail/ldap/%{user}``.
This migration is required if you both:
* enabled the LDAP integration (``mailserver.ldap.enable``)
* and customized the default mail directory (``mailserver.mailDirectory != "/var/vmail"``)
For remediating this issue the following steps are required:
1. Stop ``dovecot2.service``.
2. Move ``/var/vmail/ldap`` below your ``m̀ailserver.mailDirectory``.
3. Update the ``mailserver.stateVersion`` to ``2``.
#1 Initialization
^^^^^^^^^^^^^^^^^
This option was introduced in the NixOS 25.11 release cycle, in which case you
can safely initialize its value at `1`.
.. code-block:: nix
mailserver.stateVersion = 1;

View File

@@ -1,44 +1,6 @@
Release Notes Release Notes
============= =============
NixOS 25.11
-----------
- The ``systemName`` and ``systemDomain`` options have been introduced to have
reusable configurations for automated reports (DMARC, TLSRPT). They come with
reasonable defaults, but it is suggested to check and change them as needed.
- Support for the `Sender Rewriting Scheme`_ has been added, which allows
forwarding mail without breaking SPF by rewriting the envelope address.
- The default key length for new DKIM RSA keys was increased to 2048 bits as
recommended in `RFC 8301 3.2`_.
We recommend rotating existing keys, as the RFC advises that signatures from
1024 bit keys should not be considered valid any longer.
- IMAP access over port ``143/tcp`` is now default disabled in line
with `RFC 8314 4.1`_. Use IMAP over implicit TLS on port ``993/tcp``
instead. If you still require this feature you can reenable it using
``mailserver.enableImap``, but it is scheduled for removal after the 25.11
release.
- SMTP server and client now support and prefer a hybrid key exchange
(X25519MLKEM768)
- SMTP access over STARTTLS on port ``587/tcp`` is now default disabled in line
with `RFC 8314 3.3`_. If you still require this feature you can renable it using
``mailserver.enableSubmission``.
- DMARC reports are now sent with the ``noreply-dmarc`` localpart from the
system domain.
- DANE and MTA-STS are now validated for outgoing SMTP connections using
`postfix-tlspol`_.
- SMTP TLS connection reports (`RFC 8460`_) are now supported using
`tlsrpt-reporter`_. They can be enabled with the ``mailserver.tlsrpt.enable``
option.
.. _Sender Rewriting Scheme: srs.html
.. _RFC 8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2
.. _RFC 8314 3.3: https://www.rfc-editor.org/rfc/rfc8314#section-3.3
.. _RFC 8314 4.1: https://www.rfc-editor.org/rfc/rfc8314#section-4.1
.. _RFC 8460: https://www.rfc-editor.org/rfc/rfc8460
.. _postfix-tlspol: https://github.com/Zuplu/postfix-tlspol
.. _tlsrpt-reporter: https://github.com/sys4/tlsrpt-reporter
NixOS 25.05 NixOS 25.05
----------- -----------

View File

@@ -63,16 +63,15 @@ common ones.
imports = [ imports = [
(builtins.fetchTarball { (builtins.fetchTarball {
# Pick a release version you are interested in and set its hash, e.g. # 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"; url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.05/nixos-mailserver-nixos-25.05.tar.gz";
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command: # 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 # release="nixos-25.05"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
sha256 = "0000000000000000000000000000000000000000000000000000"; sha256 = "0000000000000000000000000000000000000000000000000000";
}) })
]; ];
mailserver = { mailserver = {
enable = true; enable = true;
stateVersion = 3;
fqdn = "mail.example.com"; fqdn = "mail.example.com";
domains = [ "example.com" ]; domains = [ "example.com" ];
@@ -238,8 +237,3 @@ Besides that, you can send an email to
score, and let `mxtoolbox.com <http://mxtoolbox.com/>`__ take a look at score, and let `mxtoolbox.com <http://mxtoolbox.com/>`__ take a look at
your setup, but if you followed the steps closely then everything should your setup, but if you followed the steps closely then everything should
be awesome! be awesome!
Next steps (optional)
~~~~~~~~~~~~~~~~~~~~~
Take a look through our `Advanced Configurations <advanced-configurations.html>`_.

View File

@@ -1,102 +0,0 @@
Sender Rewriting Scheme
=======================
The Sender Rewriting Scheme (SRS) allows mail servers to forward emails without
breaking SPF checks. By rewriting the envelope sender to an address within the
forwarders domain, SRS ensures that forwarded messages pass SPF validation,
preventing them from being rejected as spoofed or unauthorized.
How SRS works in practice
~~~~~~~~~~~~~~~~~~~~~~~~~
1. ``alice@foo.example`` receives an E-Mail from ``bob@bar.example``. Both the
envelope sender as well as the ``From`` header show ``bob@bar.example``. This
results in strict SPF alignment, because ``bar.example`` is the domain used in
both the ``Return-Path`` and ``FROM`` headers.
2. ``alice@foo.example`` forwards the mail to ``charlie@moo.example`` and
uses SRS to rewrite the envelope sender to originate from the local SRS domain
(e.g. `SRS0=HHH=TT=bar.example=alice@foo.example`). The ``FROM`` header remains
unchanged. This ensures that the forwarded mail succeeds SPF checks.
3. The email reaches ``charlie@moo.example``. SPF passes because the sender
domain in the envelope has been rewritten. The mismatch between envelope sender
domain and ``FROM`` domain does however break strict SPF alignment.
Enabling SRS
~~~~~~~~~~~~
In a simple setup just enabling SRS will use your ``mailserver.systemDomain``
when rewriting the envelope sender domain.
.. code:: nix
{
mailserver = {
srs = {
enable = true;
#domain = "srs.example.com";
};
};
};
..
While you can reuse an existing email domain for SRS, it is recommended to
configure a dedicated SRS domain. This is particularly important under the
following conditions:
* Multiple unrelated mail domains are hosted on the mailserver
* The mail domain requires strict SPF alignment in its DMARC policy
Required DNS changes
~~~~~~~~~~~~~~~~~~~~
.. note::
In the following example we assume that you want to set up a dedicated SRS
domain. If that is not the case you already have SPF and DKIM set up for the
system domain. If you have a DMARC record on the system domain, make sure it
uses a relaxed SPF alignment policy (``aspf=r``).
First we set up an MX record. This is so that we can receive and route bounces
that can result from forwards.
======================== ===== ==== ======== =====================
Name (Subdomain) TTL Type Priority Value
======================== ===== ==== ======== =====================
srs.example.com 10800 MX 10 ``mail.example.com``
======================== ===== ==== ==============================
Next up is the SPF record on the SRS domain to allow SPF authentication.
======================== ===== ==== ===================
Name (Subdomain) TTL Type Value
======================== ===== ==== ===================
srs.example.com 10800 TXT ``v=spf1 mx -all``
======================== ===== ==== ===================
Then we deploy the DKIM record with the `p=<value>` taken from
``/var/dkim/srs.example.com.mail.txt``, that appears after deploying with SRS
enabled.
=============================== ===== ==== ========================================
Name (Subdomain) TTL Type Value
=============================== ===== ==== ========================================
mail._domainkey.srs.example.com 10800 TXT ``v=DKIM1; k=rsa; p=<really-long-key>``
=============================== ===== ==== ========================================
Finally we can tie this together in the DMARC record to require receivers to
verify the requested SPF/DKIM alignment.
.. note::
The SRS domain can only support relaxed SPF alignment due to the envelope
sender and ``FROM`` header mismatch.
======================== ===== ==== =========================================
Name (Subdomain) TTL Type Value
======================== ===== ==== =========================================
_dmarc.srs.example.com 10800 TXT ``v=DMARC1; p=reject; aspf=r; adkim=s;``
======================== ===== ==== =========================================
We can safely configure a ``reject`` policy on the SRS domain, to enforce the
SPF and DKIM alignment as configured above.

39
flake.lock generated
View File

@@ -19,11 +19,11 @@
"flake-compat": { "flake-compat": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1761588595, "lastModified": 1747046372,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "edolstra", "owner": "edolstra",
"repo": "flake-compat", "repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -43,11 +43,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1763319842, "lastModified": 1742649964,
"narHash": "sha256-YG19IyrTdnVn0l3DvcUYm85u3PaqBt6tI6VvolcuHnA=", "narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=",
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "7275fa67fbbb75891c16d9dee7d88e58aea2d761", "rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -79,16 +79,32 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1764020296, "lastModified": 1747179050,
"narHash": "sha256-6zddwDs2n+n01l+1TG6PlyokDdXzu/oBmEejcH5L5+A=", "narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "a320ce8e6e2cc6b4397eef214d202a50a4583829", "rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-25.11-small", "ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-25_05": {
"locked": {
"lastModified": 1747610100,
"narHash": "sha256-rpR5ZPMkWzcnCcYYo3lScqfuzEw5Uyfh+R0EKZfroAc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ca49c4304acf0973078db0a9d200fd2bae75676d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -98,7 +114,8 @@
"blobs": "blobs", "blobs": "blobs",
"flake-compat": "flake-compat", "flake-compat": "flake-compat",
"git-hooks": "git-hooks", "git-hooks": "git-hooks",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"nixpkgs-25_05": "nixpkgs-25_05"
} }
} }
}, },

337
flake.nix
View File

@@ -12,203 +12,182 @@
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-25.11-small"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-25_05.url = "github:NixOS/nixpkgs/nixos-25.05";
blobs = { blobs = {
url = "gitlab:simple-nixos-mailserver/blobs"; url = "gitlab:simple-nixos-mailserver/blobs";
flake = false; flake = false;
}; };
}; };
outputs = outputs = { self, blobs, git-hooks, nixpkgs, nixpkgs-25_05, ... }: let
{ lib = nixpkgs.lib;
self, system = "x86_64-linux";
blobs, pkgs = nixpkgs.legacyPackages.${system};
git-hooks, releases = [
nixpkgs, {
... name = "unstable";
}: nixpkgs = nixpkgs;
let pkgs = nixpkgs.legacyPackages.${system};
lib = nixpkgs.lib; }
system = "x86_64-linux"; {
pkgs = nixpkgs.legacyPackages.${system}; name = "25.05";
releases = [ nixpkgs = nixpkgs-25_05;
{ pkgs = nixpkgs-25_05.legacyPackages.${system};
name = "nixos-25.11"; }
nixpkgs = nixpkgs; ];
pkgs = nixpkgs.legacyPackages.${system}; testNames = [
} "clamav"
]; "external"
testNames = [ "internal"
"clamav" "ldap"
"external" "multiple"
"internal" ];
"ldap"
"multiple"
];
genTest = genTest = testName: release: let
testName: release: pkgs = release.pkgs;
let nixos-lib = import (release.nixpkgs + "/nixos/lib") {
pkgs = release.pkgs; inherit (pkgs) lib;
nixos-lib = import (release.nixpkgs + "/nixos/lib") {
inherit (pkgs) lib;
};
in
{
name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}";
value = nixos-lib.runTest {
hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ];
_module.args = { inherit blobs; };
extraBaseModules.imports = [ ./default.nix ];
};
};
# Generate an attribute set such as
# {
# external-unstable = <derivation>;
# external-21_05 = <derivation>;
# ...
# }
allTests = lib.listToAttrs (lib.flatten (map (t: map (r: genTest t r) releases) testNames));
mailserverModule = import ./.;
# Generate a MarkDown file describing the options of the NixOS mailserver module
optionsDoc =
let
eval = lib.evalModules {
modules = [
mailserverModule
{
_module.check = false;
mailserver = {
fqdn = "mx.example.com";
systemDomain = "example.com";
domains = [
"example.com"
];
};
}
];
};
options = builtins.toFile "options.json" (
builtins.toJSON (
lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") (
lib.optionAttrSetToDocList eval.options
)
)
);
in
pkgs.runCommand "options.md" { buildInputs = [ pkgs.python3Minimal ]; } ''
echo "Generating options.md from ${options}"
python ${./scripts/generate-options.py} ${options} > $out
echo $out
'';
documentation = pkgs.stdenv.mkDerivation {
name = "documentation";
src = lib.sourceByRegex ./docs [
"logo\\.png"
"conf\\.py"
"Makefile"
".*\\.rst"
];
buildInputs = [
(pkgs.python3.withPackages (
p: with p; [
sphinx
sphinx-rtd-theme
myst-parser
linkify-it-py
]
))
];
buildPhase = ''
cp ${optionsDoc} options.md
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
unset SOURCE_DATE_EPOCH
make html
'';
installPhase = ''
cp -Tr _build/html $out
'';
}; };
in {
name = "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}";
value = nixos-lib.runTest {
hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ];
_module.args = { inherit blobs; };
extraBaseModules.imports = [ ./default.nix ];
};
};
in # Generate an attribute set such as
{ # {
nixosModules = rec { # external-unstable = <derivation>;
mailserver = mailserverModule; # external-21_05 = <derivation>;
default = mailserver; # ...
}; # }
nixosModule = self.nixosModules.default; # compatibility allTests = lib.listToAttrs (
hydraJobs.${system} = allTests // { lib.flatten (map (t: map (r: genTest t r) releases) testNames));
inherit documentation;
inherit (self.checks.${system}) pre-commit; mailserverModule = import ./.;
};
checks.${system} = allTests // { # Generate a MarkDown file describing the options of the NixOS mailserver module
pre-commit = git-hooks.lib.${system}.run { optionsDoc = let
src = ./.; eval = lib.evalModules {
hooks = { modules = [
# docs mailserverModule
markdownlint = { {
enable = true; _module.check = false;
settings.configuration = { mailserver = {
# Max line length, doesn't seem to correclty account for lines containing links fqdn = "mx.example.com";
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md domains = [
MD013 = false; "example.com"
];
dmarcReporting = {
organizationName = "Example Corp";
domain = "example.com";
}; };
}; };
rstcheck = { }
enable = true; ];
package = pkgs.rstcheckWithSphinx; };
entry = lib.getExe pkgs.rstcheckWithSphinx; options = builtins.toFile "options.json" (builtins.toJSON
files = "\\.rst$"; (lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver")
(lib.optionAttrSetToDocList eval.options)));
in pkgs.runCommand "options.md" { buildInputs = [pkgs.python3Minimal]; } ''
echo "Generating options.md from ${options}"
python ${./scripts/generate-options.py} ${options} > $out
echo $out
'';
documentation = pkgs.stdenv.mkDerivation {
name = "documentation";
src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"];
buildInputs = [(
pkgs.python3.withPackages (p: with p; [
sphinx
sphinx_rtd_theme
myst-parser
linkify-it-py
])
)];
buildPhase = ''
cp ${optionsDoc} options.md
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
unset SOURCE_DATE_EPOCH
make html
'';
installPhase = ''
cp -Tr _build/html $out
'';
};
in {
nixosModules = rec {
mailserver = mailserverModule;
default = mailserver;
};
nixosModule = self.nixosModules.default; # compatibility
hydraJobs.${system} = allTests // {
inherit documentation;
inherit (self.checks.${system}) pre-commit;
};
checks.${system} = allTests // {
pre-commit = git-hooks.lib.${system}.run {
src = ./.;
hooks = {
# docs
markdownlint = {
enable = true;
settings.configuration = {
# Max line length, doesn't seem to correclty account for lines containing links
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
MD013 = false;
}; };
};
rstcheck = {
enable = true;
package = pkgs.rstcheckWithSphinx;
entry = lib.getExe pkgs.rstcheckWithSphinx;
files = "\\.rst$";
};
# nix # nix
deadnix.enable = true; deadnix.enable = true;
nixfmt-rfc-style.enable = true;
# python # python
pyright.enable = true; pyright.enable = true;
ruff = { ruff = {
enable = true; enable = true;
args = [ args = [
"--extend-select" "--extend-select"
"I" "I"
]; ];
}; };
ruff-format.enable = true; ruff-format.enable = true;
# scripts # scripts
shellcheck.enable = true; shellcheck.enable = true;
# sieve # sieve
check-sieve = { check-sieve = {
enable = true; enable = true;
package = pkgs.check-sieve; package = pkgs.check-sieve;
entry = lib.getExe pkgs.check-sieve; entry = lib.getExe pkgs.check-sieve;
files = "\\.sieve$"; files = "\\.sieve$";
};
}; };
}; };
}; };
packages.${system} = {
inherit optionsDoc documentation;
};
devShells.${system}.default = pkgs.mkShellNoCC {
inputsFrom = [ documentation ];
packages =
with pkgs;
[
glab
]
++ self.checks.${system}.pre-commit.enabledPackages;
shellHook = self.checks.${system}.pre-commit.shellHook;
};
devShell.${system} = self.devShells.${system}.default; # compatibility
formatter.${system} = pkgs.nixfmt-tree;
}; };
packages.${system} = {
inherit optionsDoc documentation;
};
devShells.${system}.default = pkgs.mkShellNoCC {
inputsFrom = [ documentation ];
packages = with pkgs; [
glab
] ++ self.checks.${system}.pre-commit.enabledPackages;
shellHook = self.checks.${system}.pre-commit.shellHook;
};
devShell.${system} = self.devShells.${system}.default; # compatibility
};
} }

View File

@@ -1,85 +1,18 @@
{ config, lib, ... }:
{ {
config, assertions = lib.optionals config.mailserver.ldap.enable [
lib, {
... assertion = config.mailserver.loginAccounts == {};
}: message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts";
}
let {
mailserverRelease = "25.11"; assertion = config.mailserver.extraVirtualAliases == {};
nixpkgsRelease = lib.trivial.release; message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
releaseMismatch = }
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease; ] ++ lib.optionals (config.mailserver.enable && config.mailserver.certificateScheme != "acme") [
in {
assertion = config.mailserver.acmeCertificateName == config.mailserver.fqdn;
{ message = "When the certificate scheme is not 'acme' (mailserver.certificateScheme != \"acme\"), it is not possible to define mailserver.acmeCertificateName";
warnings = lib.optional releaseMismatch '' }
You are using ];
NixOS Mailserver version ${mailserverRelease} and
Nixpkgs version ${nixpkgsRelease}.
Using mismatched versions is likely to cause compatibility issues
and may require migrations that make an eventual rollback tricky.
It is therefore highly recommended to use a release of
NixOS mailserver that corresponds with your chosen release of Nixpkgs.
If you insist then you can disable this warning by adding
mailserver.enableNixpkgsReleaseCheck = false;
to your configuration.
'';
# We guard all assertions by requiring mailserver to be actually enabled
assertions = lib.optionals config.mailserver.enable (
[
{
assertion = config.mailserver.stateVersion != null;
message = "The `mailserver.stateVersion` option is not set. Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html to determine the proper value to initialize it at.";
}
]
++ lib.optionals config.mailserver.ldap.enable [
{
assertion = config.mailserver.loginAccounts == { };
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts";
}
{
assertion = config.mailserver.extraVirtualAliases == { };
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
}
]
++
lib.optionals (config.mailserver.ldap.enable && config.mailserver.mailDirectory != "/var/vmail")
[
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 2;
message = ''
Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.mailDirectory`.
Remediation:
- Stop the `dovecot2.service`
- Move `/var/vmail/ldap` below your `mailserver.mailDirectory`
- Increase the `stateVersion` to 2.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information.
'';
}
]
++ [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 3;
message = ''
Issue: The dovecot mail location for all users has changed and need to be migrated.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration for the required remediation steps.
'';
}
]
++ lib.optionals (config.mailserver.certificateScheme != "acme") [
{
assertion = config.mailserver.acmeCertificateName == config.mailserver.fqdn;
message = "When the certificate scheme is not 'acme' (mailserver.certificateScheme != \"acme\"), it is not possible to define mailserver.acmeCertificateName";
}
]
);
} }

View File

@@ -14,44 +14,28 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver.borgbackup; cfg = config.mailserver.borgbackup;
methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method; methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method;
autoFragment = autoFragment =
if cfg.compression.auto && cfg.compression.method == null then if cfg.compression.auto && cfg.compression.method == null
throw "compression.method must be set when using auto." then throw "compression.method must be set when using auto."
else else lib.optional cfg.compression.auto "auto";
lib.optional cfg.compression.auto "auto";
levelFragment = levelFragment =
if cfg.compression.level != null && cfg.compression.method == null then if cfg.compression.level != null && cfg.compression.method == null
throw "compression.method must be set when using compression.level." then throw "compression.method must be set when using compression.level."
else else lib.optional (cfg.compression.level != null) (toString cfg.compression.level);
lib.optional (cfg.compression.level != null) (toString cfg.compression.level); compressionFragment = lib.concatStringsSep "," (lib.flatten [autoFragment methodFragment levelFragment]);
compressionFragment = lib.concatStringsSep "," (
lib.flatten [
autoFragment
methodFragment
levelFragment
]
);
compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}"; compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}";
encryptionFragment = cfg.encryption.method; encryptionFragment = cfg.encryption.method;
passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile; passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile;
passphraseFragment = lib.optionalString (cfg.encryption.method != "none") ( passphraseFragment = lib.optionalString (cfg.encryption.method != "none")
if cfg.encryption.passphraseFile != null then (if cfg.encryption.passphraseFile != null then ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"''
''env BORG_PASSPHRASE="$(cat ${passphraseFile})"'' else throw "passphraseFile must be set when using encryption.");
else
throw "passphraseFile must be set when using encryption."
);
locations = lib.escapeShellArgs cfg.locations; locations = lib.escapeShellArgs cfg.locations;
name = lib.escapeShellArg cfg.name; name = lib.escapeShellArg cfg.name;
@@ -71,8 +55,7 @@ let
${passphraseFragment} ${pkgs.borgbackup}/bin/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 = with pkgs; [ environment.systemPackages = with pkgs; [
borgbackup borgbackup

View File

@@ -14,77 +14,57 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib }:
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
# cert :: PATH # cert :: PATH
certificatePath = certificatePath = if cfg.certificateScheme == "manual"
if cfg.certificateScheme == "manual" then then cfg.certificateFile
cfg.certificateFile else if cfg.certificateScheme == "selfsigned"
else if cfg.certificateScheme == "selfsigned" then then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem"
"${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then then "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem"
"${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem" else throw "unknown certificate scheme";
else
throw "unknown certificate scheme";
# key :: PATH # key :: PATH
keyPath = keyPath = if cfg.certificateScheme == "manual"
if cfg.certificateScheme == "manual" then then cfg.keyFile
cfg.keyFile else if cfg.certificateScheme == "selfsigned"
else if cfg.certificateScheme == "selfsigned" then then "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem"
"${cfg.certificateDirectory}/key-${cfg.fqdn}.pem" else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then then "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem"
"${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem" else throw "unknown certificate scheme";
else
throw "unknown certificate scheme";
passwordFiles = passwordFiles = let
let mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash;
mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash; in
in lib.mapAttrs (name: value:
lib.mapAttrs ( if value.hashedPasswordFile == null then
name: value: builtins.toString (mkHashFile name value.hashedPassword)
if value.hashedPasswordFile == null then else value.hashedPasswordFile) cfg.loginAccounts;
builtins.toString (mkHashFile name value.hashedPassword)
else
value.hashedPasswordFile
) cfg.loginAccounts;
# Appends the LDAP bind password to files to avoid writing this # Appends the LDAP bind password to files to avoid writing this
# password into the Nix store. # password into the Nix store.
appendLdapBindPwd = appendLdapBindPwd = {
{ name, file, prefix, suffix ? "", passwordFile, destination
name, }: pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
file, #!${pkgs.stdenv.shell}
prefix, set -euo pipefail
suffix ? "",
passwordFile,
destination,
}:
pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
#!${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}
''; '';
} }

View File

@@ -14,22 +14,9 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { options, config, pkgs, lib, ... }:
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix { with (import ./common.nix { inherit config pkgs lib; });
inherit
config
options
pkgs
lib
;
});
let let
cfg = config.mailserver; cfg = config.mailserver;
@@ -41,23 +28,20 @@ let
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext"; ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
boolToYesNo = x: if x then "yes" else "no"; boolToYesNo = x: if x then "yes" else "no";
listToLine = lib.concatStringsSep " "; listToLine = lib.concatStringsSep " ";
listToMultiAttrs = listToMultiAttrs = keyPrefix: attrs: lib.listToAttrs (lib.imap1 (n: x: {
keyPrefix: attrs: name = "${keyPrefix}${if n==1 then "" else toString n}";
lib.listToAttrs ( value = x;
lib.imap1 (n: x: { }) attrs);
name = "${keyPrefix}${if n == 1 then "" else toString n}";
value = x;
}) attrs
);
maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs"; maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs";
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8"; maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
# https://doc.dovecot.org/2.3/configuration_manual/home_directories_for_virtual_users/#ways-to-set-up-home-directory # maildir in format "/${domain}/${user}"
# Mail directory below the home directory
dovecotMaildir = dovecotMaildir =
"maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}" "maildir:${cfg.mailDirectory}/%{domain}/%{username}${maildirLayoutAppendix}${maildirUTF8FolderNames}"
+ (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}"); + (lib.optionalString (cfg.indexDir != null)
":INDEX=${cfg.indexDir}/%{domain}/%{username}"
);
postfixCfg = config.services.postfix; postfixCfg = config.services.postfix;
@@ -67,7 +51,7 @@ let
ldap_version = 3 ldap_version = 3
uris = ${lib.concatStringsSep " " cfg.ldap.uris} uris = ${lib.concatStringsSep " " cfg.ldap.uris}
${lib.optionalString cfg.ldap.startTls '' ${lib.optionalString cfg.ldap.startTls ''
tls = yes tls = yes
''} ''}
tls_require_cert = hard tls_require_cert = hard
tls_ca_cert_file = ${cfg.ldap.tlsCAFile} tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
@@ -77,11 +61,11 @@ let
base = ${cfg.ldap.searchBase} base = ${cfg.ldap.searchBase}
scope = ${mkLdapSearchScope cfg.ldap.searchScope} scope = ${mkLdapSearchScope cfg.ldap.searchScope}
${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) '' ${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) ''
user_attrs = ${cfg.ldap.dovecot.userAttrs} user_attrs = ${cfg.ldap.dovecot.userAttrs}
''} ''}
user_filter = ${cfg.ldap.dovecot.userFilter} user_filter = ${cfg.ldap.dovecot.userFilter}
${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") ''
pass_attrs = ${cfg.ldap.dovecot.passAttrs} pass_attrs = ${cfg.ldap.dovecot.passAttrs}
''} ''}
pass_filter = ${cfg.ldap.dovecot.passFilter} pass_filter = ${cfg.ldap.dovecot.passFilter}
''; '';
@@ -109,9 +93,7 @@ let
# Prevent world-readable password files, even temporarily. # Prevent world-readable password files, even temporarily.
umask 077 umask 077
for f in ${ for f in ${builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)}; do
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)
}; do
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!" echo "Expected password hash file $f does not exist!"
exit 1 exit 1
@@ -119,61 +101,51 @@ let
done done
cat <<EOF > ${passwdFile} cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" ( ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _:
lib.mapAttrsToList ( "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
name: _: "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" ) cfg.loginAccounts)}
) cfg.loginAccounts
)}
EOF EOF
cat <<EOF > ${userdbFile} cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" ( ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
lib.mapAttrsToList ( "${name}:::::::"
name: value:
"${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}" + lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}"
) cfg.loginAccounts ) cfg.loginAccounts)}
)}
EOF EOF
''; '';
junkMailboxes = builtins.attrNames ( junkMailboxes = builtins.attrNames (lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes);
lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes
);
junkMailboxNumber = builtins.length junkMailboxes; junkMailboxNumber = builtins.length junkMailboxes;
# The assertion garantees there is exactly one Junk mailbox. # The assertion garantees there is exactly one Junk mailbox.
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else ""; junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
mkLdapSearchScope = mkLdapSearchScope = scope: (
scope: if scope == "sub" then "subtree"
( else if scope == "one" then "onelevel"
if scope == "sub" then else scope
"subtree" );
else if scope == "one" then
"onelevel" dovecotModules = [
else pkgs.dovecot_pigeonhole
scope ] ++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
); # Remove and assume `false` after NixOS 25.05
haveDovecotModulesOption = options.services.dovecot2 ? "modules" && (options.services.dovecot2.modules.visible or true);
ftsPluginSettings = { ftsPluginSettings = {
fts = "flatcurve"; fts = "flatcurve";
fts_languages = listToLine cfg.fullTextSearch.languages; fts_languages = listToLine cfg.fullTextSearch.languages;
fts_tokenizers = listToLine [ fts_tokenizers = listToLine [ "generic" "email-address" ];
"generic"
"email-address"
];
fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian
fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch; fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch;
fts_filters = listToLine cfg.fullTextSearch.filters; fts_filters = listToLine cfg.fullTextSearch.filters;
fts_header_excludes = listToLine cfg.fullTextSearch.headerExcludes; fts_header_excludes = listToLine cfg.fullTextSearch.headerExcludes;
fts_autoindex = boolToYesNo cfg.fullTextSearch.autoIndex; fts_autoindex = boolToYesNo cfg.fullTextSearch.autoIndex;
fts_enforced = cfg.fullTextSearch.enforced; fts_enforced = cfg.fullTextSearch.enforced;
} } // (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude);
// (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude);
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf enable {
assertions = [ assertions = [
{ {
assertion = junkMailboxNumber == 1; assertion = junkMailboxNumber == 1;
@@ -182,43 +154,42 @@ in
]; ];
warnings = warnings =
lib.optional (lib.optional (
( (builtins.length cfg.fullTextSearch.languages > 1) &&
(builtins.length cfg.fullTextSearch.languages > 1) (builtins.elem "stopwords" cfg.fullTextSearch.filters)
&& (builtins.elem "stopwords" cfg.fullTextSearch.filters) ) ''
) Using stopwords in `mailserver.fullTextSearch.filters` with multiple
'' languages in `mailserver.fullTextSearch.languages` configured WILL
Using stopwords in `mailserver.fullTextSearch.filters` with multiple cause some searches to fail.
languages in `mailserver.fullTextSearch.languages` configured WILL
cause some searches to fail.
The recommended solution is to NOT use the stopword filter when The recommended solution is to NOT use the stopword filter when
multiple languages are present in the configuration. multiple languages are present in the configuration.
''; '')
;
# for sieve-test. Shelling it in on demand usually doesnt' work, as it reads # for sieve-test. Shelling it in on demand usually doesnt' work, as it reads
# the global config and tries to open shared libraries configured in there, # the global config and tries to open shared libraries configured in there,
# which are usually not compatible. # which are usually not compatible.
environment.systemPackages = [ environment.systemPackages = [
pkgs.dovecot_pigeonhole pkgs.dovecot_pigeonhole
] ] ++ lib.optionals (!haveDovecotModulesOption) dovecotModules;
++ 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 = lib.mkIf (!haveDovecotModulesOption) {
"dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
};
services.dovecot2 = { services.dovecot2 = lib.mkMerge [{
enable = true; enable = true;
enableImap = cfg.enableImap || cfg.enableImapSsl; enableImap = enableImap || enableImapSsl;
enablePop3 = cfg.enablePop3 || cfg.enablePop3Ssl; enablePop3 = enablePop3 || enablePop3Ssl;
enablePAM = false; enablePAM = false;
enableQuota = true; enableQuota = true;
mailGroup = cfg.vmailGroupName; mailGroup = vmailGroupName;
mailUser = cfg.vmailUserName; mailUser = vmailUserName;
mailLocation = dovecotMaildir; mailLocation = dovecotMaildir;
sslServerCert = certificatePath; sslServerCert = certificatePath;
sslServerKey = keyPath; sslServerKey = keyPath;
enableDHE = lib.mkDefault false;
enableLmtp = true; enableLmtp = true;
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [
"fts" "fts"
@@ -230,8 +201,7 @@ in
sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve"; sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve";
sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve"; sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve";
sieve_default_name = "default"; sieve_default_name = "default";
} } // (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings);
// (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings);
sieve = { sieve = {
extensions = [ extensions = [
@@ -248,18 +218,17 @@ in
''; '';
pipeBins = map lib.getExe [ 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-ham.sh"
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam") "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 = [ imapsieve.mailbox = [
{ {
name = junkMailboxName; name = junkMailboxName;
causes = [ causes = [ "COPY" "APPEND" ];
"COPY"
"APPEND"
];
before = ./dovecot/imap_sieve/report-spam.sieve; before = ./dovecot/imap_sieve/report-spam.sieve;
} }
{ {
@@ -274,7 +243,7 @@ in
extraConfig = '' extraConfig = ''
#Extra Config #Extra Config
${lib.optionalString cfg.debug.dovecot '' ${lib.optionalString debug ''
mail_debug = yes mail_debug = yes
auth_debug = yes auth_debug = yes
verbose_ssl = yes verbose_ssl = yes
@@ -283,62 +252,42 @@ in
${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) '' ${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) ''
service imap-login { service imap-login {
inet_listener imap { inet_listener imap {
${ ${if cfg.enableImap then ''
if cfg.enableImap then port = 143
'' '' else ''
port = 143 # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
'' port = 0
else ''}
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
} }
inet_listener imaps { inet_listener imaps {
${ ${if cfg.enableImapSsl then ''
if cfg.enableImapSsl then port = 993
'' ssl = yes
port = 993 '' else ''
ssl = yes # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
'' port = 0
else ''}
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
} }
} }
''} ''}
${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) '' ${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) ''
service pop3-login { service pop3-login {
inet_listener pop3 { inet_listener pop3 {
${ ${if cfg.enablePop3 then ''
if cfg.enablePop3 then port = 110
'' '' else ''
port = 110 # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
'' port = 0
else ''}
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
} }
inet_listener pop3s { inet_listener pop3s {
${ ${if cfg.enablePop3Ssl then ''
if cfg.enablePop3Ssl then port = 995
'' ssl = yes
port = 995 '' else ''
ssl = yes # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
'' port = 0
else ''}
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
} }
} }
''} ''}
@@ -356,13 +305,10 @@ in
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
} }
mail_access_groups = ${cfg.vmailGroupName} mail_access_groups = ${vmailGroupName}
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
ssl = required ssl = required
ssl_min_protocol = TLSv1 ssl_min_protocol = TLSv1
ssl_prefer_server_ciphers = no ssl_prefer_server_ciphers = no
ssl_curve_list = X25519MLKEM768:X25519:prime256v1:secp384r1
service lmtp { service lmtp {
unix_listener dovecot-lmtp { unix_listener dovecot-lmtp {
@@ -398,30 +344,20 @@ in
userdb { userdb {
driver = passwd-file driver = passwd-file
args = ${userdbFile} args = ${userdbFile}
default_fields = \ default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}
home=${cfg.mailDirectory}/%{domain}/%{username} \
uid=${builtins.toString cfg.vmailUID} \
gid=${builtins.toString cfg.vmailUID}
} }
${lib.optionalString cfg.ldap.enable '' ${lib.optionalString cfg.ldap.enable ''
passdb { passdb {
driver = ldap driver = ldap
args = ${ldapConfFile} args = ${ldapConfFile}
} }
userdb { userdb {
driver = ldap driver = ldap
args = ${ldapConfFile} args = ${ldapConfFile}
default_fields = \ default_fields = home=/var/vmail/ldap/%{user} uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID}
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 { service auth {
@@ -441,25 +377,25 @@ in
service indexer-worker { service indexer-worker {
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) '' ${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit * 1024 * 1024)} vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)}
''} ''}
} }
lda_mailbox_autosubscribe = yes lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes lda_mailbox_autocreate = yes
''; '';
}; }
(lib.mkIf haveDovecotModulesOption {
modules = dovecotModules;
})
];
systemd.services.dovecot = { systemd.services.dovecot2 = {
preStart = '' preStart = ''
${genPasswdScript} ${genPasswdScript}
'' '' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
+ (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
}; };
systemd.services.postfix.restartTriggers = [ systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]);
genPasswdScript
]
++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]);
}; };
} }

View File

@@ -14,26 +14,15 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf enable {
environment.systemPackages = environment.systemPackages = with pkgs; [
with pkgs; dovecot openssh postfix rspamd
[ ] ++ (if certificateScheme == "selfsigned" then [ openssl ] else []);
dovecot
openssh
postfix
rspamd
]
++ (if cfg.certificateScheme == "selfsigned" then [ openssl ] else [ ]);
}; };
} }

View File

@@ -24,3 +24,4 @@ in
services.kresd.enable = true; services.kresd.enable = true;
}; };
} }

View File

@@ -20,20 +20,18 @@ let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
config = lib.mkIf (cfg.enable && cfg.openFirewall) { config = with cfg; lib.mkIf (enable && openFirewall) {
networking.firewall = { networking.firewall = {
allowedTCPPorts = [ allowedTCPPorts = [ 25 ]
25 ++ lib.optional enableSubmission 587
] ++ lib.optional enableSubmissionSsl 465
++ lib.optional cfg.enableSubmission 587 ++ lib.optional enableImap 143
++ lib.optional cfg.enableSubmissionSsl 465 ++ lib.optional enableImapSsl 993
++ lib.optional cfg.enableImap 143 ++ lib.optional enablePop3 110
++ lib.optional cfg.enableImapSsl 993 ++ lib.optional enablePop3Ssl 995
++ lib.optional cfg.enablePop3 110 ++ lib.optional enableManageSieve 4190
++ lib.optional cfg.enablePop3Ssl 995 ++ lib.optional (certificateScheme == "acme-nginx") 80;
++ lib.optional cfg.enableManageSieve 4190
++ lib.optional (cfg.certificateScheme == "acme-nginx") 80;
}; };
}; };
} }

View File

@@ -14,46 +14,29 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix { { config, pkgs, lib, ... }:
inherit
config with (import ./common.nix { inherit config lib pkgs; });
options
lib
pkgs
;
});
let let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
config = config = lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) {
lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") {
{ enable = true;
services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") { virtualHosts."${cfg.fqdn}" = {
enable = true; serverName = cfg.fqdn;
virtualHosts."${cfg.fqdn}" = { serverAliases = cfg.certificateDomains;
serverName = cfg.fqdn; forceSSL = true;
serverAliases = cfg.certificateDomains; enableACME = true;
forceSSL = true;
enableACME = true;
};
};
security.acme.certs."${cfg.acmeCertificateName}" = {
extraDomainNames = lib.mkIf (cfg.certificateScheme == "acme") cfg.certificateDomains;
reloadServices = [
"postfix.service"
"dovecot.service"
];
};
}; };
};
security.acme.certs."${cfg.acmeCertificateName}".reloadServices = [
"postfix.service"
"dovecot2.service"
];
};
} }

View File

@@ -14,84 +14,45 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix { with (import ./common.nix { inherit config pkgs lib; });
inherit
config
options
lib
pkgs
;
});
let let
inherit (lib.strings) concatStringsSep; inherit (lib.strings) concatStringsSep;
cfg = config.mailserver; cfg = config.mailserver;
iniFormat = pkgs.formats.iniWithGlobalSection { };
# Merge several lookup tables. A lookup table is a attribute set where # Merge several lookup tables. A lookup table is a attribute set where
# - the key is an address (user@example.com) or a domain (@example.com) # - the key is an address (user@example.com) or a domain (@example.com)
# - the value is a list of addresses # - the value is a list of addresses
mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables; mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables;
# valiases_postfix :: Map String [String] # valiases_postfix :: Map String [String]
valiases_postfix = mergeLookupTables ( valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
lib.flatten ( (name: value:
lib.mapAttrsToList ( let to = name;
name: value: in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
let cfg.loginAccounts));
to = name; regex_valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
in (name: value:
map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name) let to = name;
) cfg.loginAccounts in map (from: {"${from}" = to;}) value.aliasesRegexp)
) cfg.loginAccounts));
);
regex_valiases_postfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "${from}" = to; }) value.aliasesRegexp
) cfg.loginAccounts
)
);
# catchAllPostfix :: Map String [String] # catchAllPostfix :: Map String [String]
catchAllPostfix = mergeLookupTables ( catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
lib.flatten ( (name: value:
lib.mapAttrsToList ( let to = name;
name: value: in map (from: {"@${from}" = to;}) value.catchAll)
let cfg.loginAccounts));
to = name;
in
map (from: { "@${from}" = to; }) value.catchAll
) cfg.loginAccounts
)
);
# all_valiases_postfix :: Map String [String] # all_valiases_postfix :: Map String [String]
all_valiases_postfix = mergeLookupTables [ all_valiases_postfix = mergeLookupTables [valiases_postfix extra_valiases_postfix];
valiases_postfix
extra_valiases_postfix
];
# attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String] # attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String]
attrsToLookupTable = attrsToLookupTable = aliases: let
aliases: lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases;
let in mergeLookupTables lookupTables;
lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases;
in
mergeLookupTables lookupTables;
# extra_valiases_postfix :: Map String [String] # extra_valiases_postfix :: Map String [String]
extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases; extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
@@ -100,49 +61,37 @@ let
forwards = attrsToLookupTable cfg.forwards; forwards = attrsToLookupTable cfg.forwards;
# lookupTableToString :: Map String [String] -> String # lookupTableToString :: Map String [String] -> String
lookupTableToString = lookupTableToString = attrs: let
attrs: valueToString = value: lib.concatStringsSep ", " value;
let in lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs);
valueToString = value: lib.concatStringsSep ", " value;
in
lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs
);
# valiases_file :: Path # valiases_file :: Path
valiases_file = valiases_file = let
let content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]);
content = lookupTableToString (mergeLookupTables [ in builtins.toFile "valias" content;
all_valiases_postfix
catchAllPostfix
]);
in
builtins.toFile "valias" content;
regex_valiases_file = regex_valiases_file = let
let content = lookupTableToString regex_valiases_postfix;
content = lookupTableToString regex_valiases_postfix; in builtins.toFile "regex_valias" content;
in
builtins.toFile "regex_valias" content;
# denied_recipients_postfix :: [ String ] # denied_recipients_postfix :: [ String ]
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") ( denied_recipients_postfix = (map
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts) (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}")
); (lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)));
denied_recipients_file = builtins.toFile "denied_recipients" ( denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients_postfix);
lib.concatStringsSep "\n" denied_recipients_postfix
);
reject_senders_postfix = map (sender: "${sender} REJECT") cfg.rejectSender; reject_senders_postfix = (map
reject_senders_file = builtins.toFile "reject_senders" ( (sender:
lib.concatStringsSep "\n" reject_senders_postfix "${sender} REJECT")
); (cfg.rejectSender));
reject_senders_file = builtins.toFile "reject_senders" (lib.concatStringsSep "\n" (reject_senders_postfix)) ;
reject_recipients_postfix = map (recipient: "${recipient} REJECT") cfg.rejectRecipients; reject_recipients_postfix = (map
(recipient:
"${recipient} REJECT")
(cfg.rejectRecipients));
# rejectRecipients :: [ Path ] # rejectRecipients :: [ Path ]
reject_recipients_file = builtins.toFile "reject_recipients" ( reject_recipients_file = builtins.toFile "reject_recipients" (lib.concatStringsSep "\n" (reject_recipients_postfix)) ;
lib.concatStringsSep "\n" reject_recipients_postfix
);
# vhosts_file :: Path # vhosts_file :: Path
vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains); vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains);
@@ -154,51 +103,45 @@ let
# every alias is owned (uniquely) by its user. # every alias is owned (uniquely) by its user.
# The user's own address is already in all_valiases_postfix. # The user's own address is already in all_valiases_postfix.
vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix); vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix);
regex_vaccounts_file = builtins.toFile "regex_vaccounts" ( regex_vaccounts_file = builtins.toFile "regex_vaccounts" (lookupTableToString regex_valiases_postfix);
lookupTableToString regex_valiases_postfix
);
submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" ( submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" (''
'' # Removes sensitive headers from mails handed in via the submission port.
# Removes sensitive headers from mails handed in via the submission port. # See https://thomas-leister.de/mailserver-debian-stretch/
# See https://thomas-leister.de/mailserver-debian-stretch/ # Uses "pcre" style regex.
# Uses "pcre" style regex.
/^Received:/ IGNORE /^Received:/ IGNORE
/^X-Originating-IP:/ IGNORE /^X-Originating-IP:/ IGNORE
/^X-Mailer:/ IGNORE /^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE /^User-Agent:/ IGNORE
/^X-Enigmail:/ IGNORE /^X-Enigmail:/ IGNORE
'' '' + lib.optionalString cfg.rewriteMessageId ''
+ lib.optionalString cfg.rewriteMessageId ''
# Replaces the user submitted hostname with the server's FQDN to hide the # Replaces the user submitted hostname with the server's FQDN to hide the
# user's host or network. # user's host or network.
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
'' '');
);
smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ]; smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ];
mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}"; mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}";
submissionOptions = { submissionOptions =
smtpd_tls_security_level = "encrypt"; {
smtpd_sasl_auth_enable = "yes"; smtpd_tls_security_level = "encrypt";
smtpd_sasl_type = "dovecot"; smtpd_sasl_auth_enable = "yes";
smtpd_sasl_path = "/run/dovecot2/auth"; smtpd_sasl_type = "dovecot";
smtpd_sasl_security_options = "noanonymous"; smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_local_domain = "$myhostname"; smtpd_sasl_security_options = "noanonymous";
smtpd_client_restrictions = "permit_sasl_authenticated,reject"; smtpd_sasl_local_domain = "$myhostname";
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${ smtpd_client_restrictions = "permit_sasl_authenticated,reject";
lib.optionalString (regex_valiases_postfix != { }) ",pcre:/etc/postfix/regex_vaccounts" smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${lib.optionalString (regex_valiases_postfix != {}) ",pcre:/etc/postfix/regex_vaccounts"}";
}"; smtpd_sender_restrictions = "reject_sender_login_mismatch";
smtpd_sender_restrictions = "reject_sender_login_mismatch"; smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; cleanup_service_name = "submission-header-cleanup";
cleanup_service_name = "submission-header-cleanup"; };
};
commonLdapConfig = '' commonLdapConfig = ''
server_host = ${lib.concatStringsSep " " cfg.ldap.uris} server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
@@ -243,55 +186,20 @@ let
}; };
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf enable {
# SMTP TLS error reporting (RFC 8460)
services.tlsrpt = {
inherit (cfg.tlsrpt) enable;
configurePostfix = true;
reportd.settings = {
organization_name = cfg.systemName;
contact_info = "${cfg.systemContact}";
sender_address = "noreply-tlsrpt@${cfg.systemDomain}";
};
};
# SMTP client policy mapping for DANE (RFC 6698) and MTA-STS (RFC 8461)
services.postfix-tlspol = {
enable = true;
configurePostfix = true;
};
# Sender Rewriting Scheme (https://www.libsrs2.net/srs/srs.pdf)
services.postsrsd = {
inherit (cfg.srs) enable;
configurePostfix = true;
settings = {
domains = lib.unique (
[
cfg.fqdn
cfg.sendingFqdn
cfg.systemDomain
]
++ cfg.domains
);
separator = "=";
srs-domain = cfg.srs.domain;
};
};
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable { systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
preStart = '' preStart = ''
${appendPwdInVirtualMailboxMap} ${appendPwdInVirtualMailboxMap}
${appendPwdInSenderLoginMap} ${appendPwdInSenderLoginMap}
''; '';
restartTriggers = [ restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ];
appendPwdInVirtualMailboxMap
appendPwdInSenderLoginMap
];
}; };
services.postfix = { services.postfix = {
enable = true; enable = true;
hostname = "${sendingFqdn}";
networksStyle = "host";
mapFiles."valias" = valiases_file; mapFiles."valias" = valiases_file;
mapFiles."regex_valias" = regex_valiases_file; mapFiles."regex_valias" = regex_valiases_file;
mapFiles."vaccounts" = vaccounts_file; mapFiles."vaccounts" = vaccounts_file;
@@ -299,54 +207,50 @@ in
mapFiles."denied_recipients" = denied_recipients_file; mapFiles."denied_recipients" = denied_recipients_file;
mapFiles."reject_senders" = reject_senders_file; mapFiles."reject_senders" = reject_senders_file;
mapFiles."reject_recipients" = reject_recipients_file; mapFiles."reject_recipients" = reject_recipients_file;
sslCert = certificatePath;
sslKey = keyPath;
enableSubmission = cfg.enableSubmission; enableSubmission = cfg.enableSubmission;
enableSubmissions = cfg.enableSubmissionSsl; enableSubmissions = cfg.enableSubmissionSsl;
virtual = lookupTableToString (mergeLookupTables [ virtual = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix forwards]);
all_valiases_postfix
catchAllPostfix
forwards
]);
settings.main = { config = {
myhostname = cfg.sendingFqdn; # Extra Config
mydestination = ""; # disable local mail delivery mydestination = "";
recipient_delimiter = cfg.recipientDelimiter; recipient_delimiter = cfg.recipientDelimiter;
smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; smtpd_banner = "${fqdn} ESMTP NO UCE";
disable_vrfy_command = true; disable_vrfy_command = true;
message_size_limit = cfg.messageSizeLimit; message_size_limit = toString cfg.messageSizeLimit;
# virtual mail system # virtual mail system
virtual_uid_maps = "static:5000"; virtual_uid_maps = "static:5000";
virtual_gid_maps = "static:5000"; virtual_gid_maps = "static:5000";
virtual_mailbox_base = cfg.mailDirectory; virtual_mailbox_base = mailDirectory;
virtual_mailbox_domains = vhosts_file; virtual_mailbox_domains = vhosts_file;
virtual_mailbox_maps = [ virtual_mailbox_maps = [
(mappedFile "valias") (mappedFile "valias")
] ] ++ lib.optionals (cfg.ldap.enable) [
++ lib.optionals cfg.ldap.enable [
"ldap:${ldapVirtualMailboxMapFile}" "ldap:${ldapVirtualMailboxMapFile}"
] ] ++ lib.optionals (regex_valiases_postfix != {}) [
++ lib.optionals (regex_valiases_postfix != { }) [
(mappedRegexFile "regex_valias") (mappedRegexFile "regex_valias")
]; ];
virtual_alias_maps = lib.mkAfter ( virtual_alias_maps = lib.mkAfter (lib.optionals (regex_valiases_postfix != {}) [
lib.optionals (regex_valiases_postfix != { }) [ (mappedRegexFile "regex_valias")
(mappedRegexFile "regex_valias") ]);
]
);
virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
# Avoid leakage of X-Original-To, X-Delivered-To headers between recipients # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients
lmtp_destination_recipient_limit = "1"; lmtp_destination_recipient_limit = "1";
# Opportunistic DANE support
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
smtp_dns_support_level = "dnssec";
smtp_tls_security_level = "dane";
# sasl with dovecot # sasl with dovecot
smtpd_sasl_type = "dovecot"; smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth"; smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_auth_enable = true; smtpd_sasl_auth_enable = true;
smtpd_relay_restrictions = [ smtpd_relay_restrictions = [
"permit_mynetworks" "permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination"
"permit_sasl_authenticated"
"reject_unauth_destination"
]; ];
# reject selected senders # reject selected senders
@@ -362,92 +266,52 @@ in
"check_policy_service unix:/run/dovecot2/quota-status" "check_policy_service unix:/run/dovecot2/quota-status"
]; ];
# The X509 private key followed by the corresponding certificate # TLS settings, inspired by https://github.com/jeaye/nix-files
smtpd_tls_chain_files = [ # Submission by mail clients is handled in submissionOptions
"${keyPath}"
"${certificatePath}"
];
# TLS for incoming mail is optional
smtpd_tls_security_level = "may"; smtpd_tls_security_level = "may";
# But required for authentication attempts # Disable obselete protocols
smtpd_tls_auth_only = true; smtpd_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
smtp_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
smtpd_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
smtp_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, TLSv1, !SSLv2, !SSLv3";
# TLS versions supported for the SMTP server smtp_tls_ciphers = "high";
smtpd_tls_protocols = ">=TLSv1";
smtpd_tls_mandatory_protocols = ">=TLSv1";
# Require ciphersuites that OpenSSL classifies as "High"
smtpd_tls_ciphers = "high"; smtpd_tls_ciphers = "high";
smtp_tls_mandatory_ciphers = "high";
smtpd_tls_mandatory_ciphers = "high"; smtpd_tls_mandatory_ciphers = "high";
# Exclude cipher suites with undesirable properties # Disable deprecated ciphers
smtpd_tls_exclude_ciphers = "SHA1, eNULL, aNULL"; smtpd_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
smtpd_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL"; smtpd_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
smtp_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
smtp_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
# Enable DNSSEC/DANE support for outgoing SMTP connections tls_preempt_cipherlist = true;
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
smtp_dns_support_level = "dnssec";
smtp_tls_security_level = "dane";
# TLS versions supported for the SMTP client
smtp_tls_protocols = ">=TLSv1.2";
smtp_tls_mandatory_protocols = ">=TLSv1.2";
# Require ciphersuites that OpenSSL classifies as "High"
smtp_tls_ciphers = "high";
smtp_tls_mandatory_ciphers = "high";
# Exclude ciphersuites with undesirable properties
smtp_tls_exclude_ciphers = "SHA1, eNULL, aNULL";
smtp_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL";
# Restrict and prioritize the following curves in the given order
# Excludes curves that have no widespread support, so we don't bloat the handshake needlessly.
# https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves
tls_config_file =
let
mkGroupString = groups: concatStringsSep " / " (map (concatStringsSep ":") groups);
in
iniFormat.generate "postfix-openssl.cnf" {
globalSection.postfix = "postfix_settings";
sections = {
postfix_settings.ssl_conf = "postfix_ssl_settings";
postfix_ssl_settings.system_default = "baseline_postfix_settings";
baseline_postfix_settings.Groups = mkGroupString [
[ "*X25519MLKEM768" ]
[ "*X25519" ]
[
"P-256"
"P-384"
]
];
};
};
tls_config_name = "postfix";
# Algorithm selection happens through `tls_config_file` instead.
tls_eecdh_auto_curves = [ ];
tls_ffdhe_auto_groups = [ ];
# As long as all cipher suites are considered safe, let the client use its preferred cipher
tls_preempt_cipherlist = false;
# Allowing AUTH on a non encrypted connection poses a security risk
smtpd_tls_auth_only = true;
# Log only a summary message on TLS handshake completion # Log only a summary message on TLS handshake completion
smtp_tls_loglevel = "1"; smtp_tls_loglevel = "1";
smtpd_tls_loglevel = "1"; smtpd_tls_loglevel = "1";
# Configure a non blocking source of randomness
tls_random_source = "dev:/dev/urandom";
smtpd_milters = smtpdMilters; smtpd_milters = smtpdMilters;
non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ]; non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ];
milter_protocol = "6"; milter_protocol = "6";
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}"; milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}";
# Fix for https://www.postfix.org/smtp-smuggling.html
smtpd_forbid_bare_newline = cfg.smtpdForbidBareNewline;
smtpd_forbid_bare_newline_exclusions = "$mynetworks";
}; };
submissionOptions = submissionOptions; submissionOptions = submissionOptions;
submissionsOptions = submissionOptions; submissionsOptions = submissionOptions;
settings.master = { masterConfig = {
"lmtp" = { "lmtp" = {
# Add headers when delivering, see http://www.postfix.org/smtp.8.html # Add headers when delivering, see http://www.postfix.org/smtp.8.html
# D => Delivered-To, O => X-Original-To, R => Return-Path # D => Delivered-To, O => X-Original-To, R => Return-Path
@@ -459,10 +323,7 @@ in
chroot = false; chroot = false;
maxproc = 0; maxproc = 0;
command = "cleanup"; command = "cleanup";
args = [ args = ["-o" "header_checks=pcre:${submissionHeaderCleanupRules}"];
"-o"
"header_checks=pcre:${submissionHeaderCleanupRules}"
];
}; };
}; };
}; };

View File

@@ -14,19 +14,11 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
pkgs, with lib;
lib,
...
}:
let let
inherit (lib)
optionalString
mkIf
;
cfg = config.mailserver; cfg = config.mailserver;
preexecDefined = cfg.backup.cmdPreexec != null; preexecDefined = cfg.backup.cmdPreexec != null;
@@ -46,8 +38,7 @@ let
${cfg.backup.cmdPostexec} ${cfg.backup.cmdPostexec}
''; '';
postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}"; postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}";
in in {
{
config = mkIf (cfg.enable && cfg.backup.enable) { config = mkIf (cfg.enable && cfg.backup.enable) {
services.rsnapshot = { services.rsnapshot = {
enable = true; enable = true;

View File

@@ -14,12 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
@@ -31,127 +26,94 @@ let
rspamdUser = config.services.rspamd.user; rspamdUser = config.services.rspamd.user;
rspamdGroup = config.services.rspamd.group; rspamdGroup = config.services.rspamd.group;
createDkimKeypair = createDkimKeypair = domain: let
domain: privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
let publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key"; in pkgs.writeShellScript "dkim-keygen-${domain}" ''
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt"; if [ ! -f "${privateKey}" ]
in then
pkgs.writeShellScript "dkim-keygen-${domain}" '' ${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \
if [ ! -f "${privateKey}" ] --domain "${domain}" \
then --selector "${cfg.dkimSelector}" \
${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \ --type "${cfg.dkimKeyType}" \
--domain "${domain}" \ --bits ${toString cfg.dkimKeyBits} \
--selector "${cfg.dkimSelector}" \ --privkey "${privateKey}" > "${publicKey}"
--type "${cfg.dkimKeyType}" \ chmod 0644 "${publicKey}"
--bits ${toString cfg.dkimKeyBits} \ echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
--privkey "${privateKey}" > "${publicKey}" fi
chmod 0644 "${publicKey}" '';
echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
fi
'';
dkimDomains = lib.unique (cfg.domains ++ (lib.optionals cfg.srs.enable [ cfg.srs.domain ]));
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf enable {
environment.systemPackages = lib.mkBefore [ environment.systemPackages = lib.mkBefore [
(pkgs.runCommand "rspamc-wrapped" (pkgs.runCommand "rspamc-wrapped" {
{ nativeBuildInputs = with pkgs; [ makeWrapper ];
nativeBuildInputs = with pkgs; [ makeWrapper ]; }''
} makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \
'' --add-flags "-h /run/rspamd/worker-controller.sock"
makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \ '')
--add-flags "-h /run/rspamd/worker-controller.sock"
''
)
]; ];
services.rspamd = { services.rspamd = {
enable = true; enable = true;
debug = cfg.debug.rspamd; inherit debug;
locals = { locals = {
"milter_headers.conf" = { "milter_headers.conf" = { text = ''
text = '' extended_spam_headers = true;
extended_spam_headers = true; ''; };
''; "redis.conf" = { text = ''
}; servers = "${if cfg.redis.port == null
"redis.conf" = { then
text = '' cfg.redis.address
servers = "${ else
if cfg.redis.port == null then "${cfg.redis.address}:${toString cfg.redis.port}"}";
cfg.redis.address '' + (lib.optionalString (cfg.redis.password != null) ''
else password = "${cfg.redis.password}";
"${cfg.redis.address}:${toString cfg.redis.port}" ''); };
}"; "classifier-bayes.conf" = { text = ''
'' cache {
+ (lib.optionalString (cfg.redis.password != null) '' backend = "redis";
password = "${cfg.redis.password}"; }
''); ''; };
}; "antivirus.conf" = lib.mkIf cfg.virusScanning { text = ''
"classifier-bayes.conf" = { clamav {
text = '' action = "reject";
cache { symbol = "CLAM_VIRUS";
backend = "redis"; type = "clamav";
} log_clean = true;
''; servers = "/run/clamav/clamd.ctl";
}; scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all
"antivirus.conf" = lib.mkIf cfg.virusScanning { }
text = '' ''; };
clamav { "dkim_signing.conf" = { text = ''
action = "reject";
symbol = "CLAM_VIRUS";
type = "clamav";
log_clean = true;
servers = "/run/clamav/clamd.ctl";
scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all
}
'';
};
"dkim_signing.conf" = {
text = ''
enabled = ${lib.boolToString cfg.dkimSigning}; enabled = ${lib.boolToString cfg.dkimSigning};
path = "${cfg.dkimKeyDirectory}/$domain.$selector.key"; path = "${cfg.dkimKeyDirectory}/$domain.$selector.key";
selector = "${cfg.dkimSelector}"; 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 ''; };
use_esld = false; "dmarc.conf" = { text = ''
''; ${lib.optionalString cfg.dmarcReporting.enable ''
};
"dmarc.conf" = {
text = ''
${lib.optionalString cfg.dmarcReporting.enable ''
reporting { reporting {
enabled = true; enabled = true;
email = "noreply-dmarc@${cfg.systemDomain}"; email = "${cfg.dmarcReporting.email}";
domain = "${cfg.systemDomain}"; domain = "${cfg.dmarcReporting.domain}";
org_name = "${cfg.systemName}"; org_name = "${cfg.dmarcReporting.organizationName}";
from_name = "${cfg.systemName}"; from_name = "${cfg.dmarcReporting.fromName}";
msgid_from = "${cfg.systemDomain}"; msgid_from = "${cfg.dmarcReporting.domain}";
${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) '' ${lib.optionalString (cfg.dmarcReporting.excludeDomains != []) ''
exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains}; exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains};
''} ''}
}''} }''}
''; ''; };
};
};
overrides = {
"options.inc" = {
text = ''
local_addrs = [::1/128, 127.0.0.0/8]
'';
};
}; };
workers.rspamd_proxy = { workers.rspamd_proxy = {
type = "rspamd_proxy"; type = "rspamd_proxy";
bindSockets = [ bindSockets = [{
{ socket = "/run/rspamd/rspamd-milter.sock";
socket = "/run/rspamd/rspamd-milter.sock"; mode = "0664";
mode = "0664"; }];
}
];
count = 1; # Do not spawn too many processes of this type count = 1; # Do not spawn too many processes of this type
extraConfig = '' extraConfig = ''
milter = yes; # Enable milter mode milter = yes; # Enable milter mode
@@ -166,13 +128,11 @@ in
workers.controller = { workers.controller = {
type = "controller"; type = "controller";
count = 1; count = 1;
bindSockets = [ bindSockets = [{
{ socket = "/run/rspamd/worker-controller.sock";
socket = "/run/rspamd/worker-controller.sock"; mode = "0666";
mode = "0666"; }];
} includes = [];
];
includes = [ ];
extraConfig = '' extraConfig = ''
static_dir = "''${WWWDIR}"; # Serve the web UI static assets static_dir = "''${WWWDIR}"; # Serve the web UI static assets
''; '';
@@ -180,7 +140,7 @@ in
}; };
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally; services.redis.servers.rspamd.enable = lib.mkDefault true;
systemd.tmpfiles.settings."10-rspamd.conf" = { systemd.tmpfiles.settings."10-rspamd.conf" = {
"${cfg.dkimKeyDirectory}" = { "${cfg.dkimKeyDirectory}" = {
@@ -205,26 +165,24 @@ in
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ]; SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
} }
(lib.optionalAttrs cfg.dkimSigning { (lib.optionalAttrs cfg.dkimSigning {
ExecStartPre = map createDkimKeypair dkimDomains; ExecStartPre = map createDkimKeypair cfg.domains;
ReadWritePaths = [ cfg.dkimKeyDirectory ]; ReadWritePaths = [ cfg.dkimKeyDirectory ];
}) })
]; ];
}; };
systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
# Explicitly select yesterday's date to work around broken # Explicitly select yesterday's date to work around broken
# default behaviour when called without a date. # default behaviour when called without a date.
# https://github.com/rspamd/rspamd/issues/4062 # https://github.com/rspamd/rspamd/issues/4062
script = toString [ script = ''
(lib.getExe' pkgs.rspamd "rspamadm") ${pkgs.rspamd}/bin/rspamadm dmarc_report $(date -d "yesterday" "+%Y%m%d")
"dmarc_report" '';
"$(date -d 'yesterday' '+%Y%m%d')"
];
serviceConfig = { serviceConfig = {
User = "${config.services.rspamd.user}"; User = "${config.services.rspamd.user}";
Group = "${config.services.rspamd.group}"; Group = "${config.services.rspamd.group}";
AmbientCapabilities = [ ]; AmbientCapabilities = [];
CapabilityBoundingSet = ""; CapabilityBoundingSet = "";
DevicePolicy = "closed"; DevicePolicy = "closed";
IPAddressAllow = "localhost"; IPAddressAllow = "localhost";
@@ -245,17 +203,10 @@ in
ProcSubset = "pid"; ProcSubset = "pid";
ProtectSystem = "strict"; ProtectSystem = "strict";
RemoveIPC = true; RemoveIPC = true;
RestrictAddressFamilies = [ RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true; RestrictNamespaces = true;
RestrictRealtime = true; RestrictRealtime = true;
RestrictSUIDSGID = true; RestrictSUIDSGID = true;
SupplementaryGroups = lib.optionals cfg.redis.configureLocally [
config.services.redis.servers.rspamd.group
];
SystemCallArchitectures = "native"; SystemCallArchitectures = "native";
SystemCallFilter = [ SystemCallFilter = [
"@system-service" "@system-service"
@@ -265,7 +216,7 @@ in
}; };
}; };
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
description = "Daily delivery of aggregated DMARC reports"; description = "Daily delivery of aggregated DMARC reports";
wantedBy = [ wantedBy = [
"timers.target" "timers.target"
@@ -286,3 +237,4 @@ in
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ]; users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
}; };
} }

View File

@@ -14,92 +14,72 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
let let
cfg = config.mailserver; cfg = config.mailserver;
certificatesDeps = certificatesDeps =
if cfg.certificateScheme == "manual" then if cfg.certificateScheme == "manual" then
[ ] []
else if cfg.certificateScheme == "selfsigned" then else if cfg.certificateScheme == "selfsigned" then
[ "mailserver-selfsigned-certificate.service" ] [ "mailserver-selfsigned-certificate.service" ]
else else
[ "acme-finished-${cfg.fqdn}.target" ]; [ "acme-finished-${cfg.fqdn}.target" ];
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf enable {
# Create self signed certificate # Create self signed certificate
systemd.services.mailserver-selfsigned-certificate = systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == "selfsigned") {
lib.mkIf (cfg.certificateScheme == "selfsigned") after = [ "local-fs.target" ];
{ script = ''
after = [ "local-fs.target" ]; # Create certificates if they do not exist yet
script = '' dir="${cfg.certificateDirectory}"
# Create certificates if they do not exist yet fqdn="${cfg.fqdn}"
dir="${cfg.certificateDirectory}" [[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
fqdn="${cfg.fqdn}" key="$dir/key-${cfg.fqdn}.pem";
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn") cert="$dir/cert-${cfg.fqdn}.pem";
key="$dir/key-${cfg.fqdn}.pem";
cert="$dir/cert-${cfg.fqdn}.pem";
if [[ ! -f $key || ! -f $cert ]]; then if [[ ! -f $key || ! -f $cert ]]; then
mkdir -p "${cfg.certificateDirectory}" mkdir -p "${cfg.certificateDirectory}"
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) && (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) &&
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \ "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \
-days 3650 -out "$cert" -days 3650 -out "$cert"
fi fi
''; '';
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
PrivateTmp = true; PrivateTmp = true;
}; };
}; };
# Create maildir folder before dovecot startup # Create maildir folder before dovecot startup
systemd.services.dovecot = { systemd.services.dovecot2 = {
wants = certificatesDeps; wants = certificatesDeps;
after = certificatesDeps; after = certificatesDeps;
preStart = preStart = let
let directories = lib.strings.escapeShellArgs (
directories = lib.strings.escapeShellArgs ( [ mailDirectory ]
[ cfg.mailDirectory ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir ++ lib.optional (cfg.indexDir != null) cfg.indexDir
); );
in in ''
'' # Create mail directory and set permissions. See
# Create mail directory and set permissions. See # <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>.
# <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>. # Prevent world-readable paths, even temporarily.
# Prevent world-readable paths, even temporarily. umask 007
umask 007 mkdir -p ${directories}
mkdir -p ${directories} chgrp "${vmailGroupName}" ${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 = certificatesDeps; wants = certificatesDeps;
after = [ after = [ "dovecot2.service" ]
"dovecot.service" ++ lib.optional cfg.dkimSigning "rspamd.service"
] ++ certificatesDeps;
++ lib.optional cfg.dkimSigning "rspamd.service" requires = [ "dovecot2.service" ]
++ certificatesDeps; ++ lib.optional cfg.dkimSigning "rspamd.service";
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service";
}; };
}; };
} }

View File

@@ -14,22 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
with config.mailserver; with config.mailserver;
@@ -43,6 +28,7 @@ let
group = vmailGroupName; group = vmailGroupName;
}; };
virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" '' virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" ''
#!${pkgs.stdenv.shell} #!${pkgs.stdenv.shell}
@@ -60,54 +46,45 @@ let
# Copy user's sieve script to the correct location (if it exists). If it # Copy user's sieve script to the correct location (if it exists). If it
# is null, remove the file. # is null, remove the file.
${lib.concatMapStringsSep "\n" ( ${lib.concatMapStringsSep "\n" ({ name, sieveScript }:
{ name, sieveScript }: if lib.isString sieveScript then ''
if lib.isString sieveScript then if (! test -d "${sieveDirectory}/${name}"); then
'' mkdir -p "${sieveDirectory}/${name}"
if (! test -d "${sieveDirectory}/${name}"); then chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
mkdir -p "${sieveDirectory}/${name}" chmod 770 "${sieveDirectory}/${name}"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}" fi
chmod 770 "${sieveDirectory}/${name}" cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve"
fi ${sieveScript}
cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve" EOF
${sieveScript} chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
EOF '' else ''
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve" if (test -f "${sieveDirectory}/${name}/default.sieve"); then
'' rm "${sieveDirectory}/${name}/default.sieve"
else fi
'' if (test -f "${sieveDirectory}/${name}.svbin"); then
if (test -f "${sieveDirectory}/${name}/default.sieve"); then rm "${sieveDirectory}/${name}/default.svbin"
rm "${sieveDirectory}/${name}/default.sieve" fi
fi '') (map (user: { inherit (user) name sieveScript; })
if (test -f "${sieveDirectory}/${name}.svbin"); then (lib.attrValues loginAccounts))}
rm "${sieveDirectory}/${name}/default.svbin"
fi
''
) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))}
''; '';
in in {
{
config = lib.mkIf 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 = acct.hashedPassword != null || acct.hashedPasswordFile != null; assertion = (acct.hashedPassword != null || acct.hashedPasswordFile != null);
message = "${acct.name} must provide either a hashed password or a password hash file"; message = "${acct.name} must provide either a hashed password or a password hash file";
}) (lib.attrValues loginAccounts); }) (lib.attrValues loginAccounts));
# warn for accounts that specify both password and file # warn for accounts that specify both password and file
warnings = warnings = (map
map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
( (lib.filter
lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) ( (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null))
lib.attrValues loginAccounts (lib.attrValues loginAccounts)));
)
);
# set the vmail gid to a specific value # set the vmail gid to a specific value
users.groups = { users.groups = {
"${vmailGroupName}" = { "${vmailGroupName}" = { gid = vmailUID; };
gid = vmailUID;
};
}; };
# define all users # define all users
@@ -117,7 +94,7 @@ in
systemd.services.activate-virtual-mail-users = { systemd.services.activate-virtual-mail-users = {
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
before = [ "dovecot.service" ]; before = [ "dovecot2.service" ];
serviceConfig = { serviceConfig = {
ExecStart = virtualMailUsersActivationScript; ExecStart = virtualMailUsersActivationScript;
}; };

View File

@@ -1,146 +0,0 @@
#!/usr/bin/env nix-shell
#!nix-shell -i python3 -p python3
import argparse
import os
import shutil
import sys
from enum import Enum
from pathlib import Path
from pwd import getpwnam
class FolderLayout(Enum):
Default = 1
Folder = 2
def check_user(vmail_root: Path):
owner = vmail_root.owner()
owner_uid = getpwnam(owner).pw_uid
if os.geteuid() == owner_uid:
return
try:
print(
f"Trying to switch effective user id to {owner_uid} ({owner})",
file=sys.stderr,
)
os.seteuid(owner_uid)
return
except PermissionError:
print(
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)",
file=sys.stderr,
)
sys.exit(1)
def is_maildir_related(path: Path, layout: FolderLayout) -> bool:
if path.name in [
"subscriptions",
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-uid-mapping
"dovecot-uidlist",
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-keywords
"dovecot-keywords",
]:
return True
if not path.is_dir():
return False
if path.name in ["cur", "new", "tmp"]:
return True
if layout is FolderLayout.Default and path.name.startswith("."):
return True
if layout is FolderLayout.Folder:
if path.name in ["mail"]:
return False
return True
return False
def mkdir(dst: Path, dry_run: bool = True):
print(f'mkdir "{dst}"')
if not dry_run:
# u+rwx, setgid
dst.mkdir(mode=0o2700)
def move(src: Path, dst: Path, dry_run: bool = True):
print(f'mv "{src}" "{dst}"')
if not dry_run:
src.rename(dst)
def delete(dst: Path, dry_run: bool = True):
if not dst.exists():
return
if dst.is_dir():
print(f'rm --recursive "{dst}"')
if not dry_run:
shutil.rmtree(dst)
else:
print(f'rm "{dst}"')
if not dry_run:
dst.unlink()
def main(vmail_root: Path, layout: FolderLayout, dry_run: bool = True):
maildirs = {path.parent for path in vmail_root.glob("*/*/cur")}
maybe_delete = []
# The old maildir will be the new home directory
for homedir in maildirs:
maildir = homedir / "mail"
mkdir(maildir, dry_run)
for path in homedir.iterdir():
if is_maildir_related(path, layout):
move(path, maildir / path.name, dry_run)
else:
maybe_delete.append(path)
# Files that are part of the previous home directory, but now obsolete
for path in [
vmail_root / ".dovecot.lda-dupes",
vmail_root / ".dovecot.lda-dupes.locks",
]:
delete(path, dry_run)
# The remaining files are likely obsolete, but should still be checked with care
for path in maybe_delete:
print(f"# rm {str(path)}")
if dry_run:
print("\nNo changes were made.")
print("Run the script with `--execute` to apply the listed changes.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="""
NixOS Mailserver Migration #3: Dovecot mail directory migration
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration)
"""
)
parser.add_argument(
"vmail_root", type=Path, help="Path to the `mailserver.mailDirectory`"
)
parser.add_argument(
"--layout",
choices=["default", "folder"],
required=True,
help="Folder layout: 'default' unless `mailserver.useFsLayout` was enabled, then'folder'",
)
parser.add_argument(
"--execute", action="store_true", help="Actually perform changes"
)
args = parser.parse_args()
layout = FolderLayout.Default if args.layout == "default" else FolderLayout.Folder
check_user(args.vmail_root)
main(args.vmail_root, layout, not args.execute)

View File

@@ -1,5 +0,0 @@
[tool.ruff.lint]
extend-select = ["ISC"]
[tool.ruff.lint.flake8-implicit-str-concat]
allow-multiline = false

View File

@@ -27,7 +27,6 @@ groups = [
"mailserver.loginAccounts", "mailserver.loginAccounts",
"mailserver.certificate", "mailserver.certificate",
"mailserver.dkim", "mailserver.dkim",
"mailserver.srs",
"mailserver.dmarcReporting", "mailserver.dmarcReporting",
"mailserver.fullTextSearch", "mailserver.fullTextSearch",
"mailserver.redis", "mailserver.redis",
@@ -91,9 +90,7 @@ def print_option(option):
key=option["name"], key=option["name"],
description=description or "", description=description or "",
type=f"- type: {md_literal(option['type'])}", type=f"- type: {md_literal(option['type'])}",
default=render_option_value(option, "defaultText") default=render_option_value(option, "default"),
if "defaultText" in option
else render_option_value(option, "default"),
example=render_option_value(option, "example"), example=render_option_value(option, "example"),
) )
) )

View File

@@ -12,15 +12,7 @@ RETRY = 100
def _send_mail( def _send_mail(
smtp_host, smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls
smtp_port,
smtp_username,
from_addr,
from_pwd,
to_addr,
subject,
starttls,
ssl,
): ):
print(f"Sending mail with subject '{subject}'") print(f"Sending mail with subject '{subject}'")
message = "\n".join( message = "\n".join(
@@ -36,10 +28,9 @@ def _send_mail(
) )
retry = RETRY retry = RETRY
smtp_class = smtplib.SMTP_SSL if ssl else smtplib.SMTP
while True: while True:
try: try:
with smtp_class(smtp_host, port=smtp_port) as smtp: with smtplib.SMTP(smtp_host, port=smtp_port) as smtp:
try: try:
if starttls: if starttls:
smtp.starttls() smtp.starttls()
@@ -82,7 +73,7 @@ def _read_mail(
show_body=False, show_body=False,
delete=True, delete=True,
): ):
print(f"Reading mail from {imap_username}") print("Reading mail from {imap_username}")
message = None message = None
@@ -180,7 +171,6 @@ def send_and_read(args):
to_addr=args.to_addr, to_addr=args.to_addr,
subject=subject, subject=subject,
starttls=args.smtp_starttls, starttls=args.smtp_starttls,
ssl=args.smtp_ssl,
) )
_read_mail( _read_mail(
@@ -216,7 +206,6 @@ parser_send_and_read = subparsers.add_parser(
parser_send_and_read.add_argument("--smtp-host", type=str) parser_send_and_read.add_argument("--smtp-host", type=str)
parser_send_and_read.add_argument("--smtp-port", type=str, default=25) parser_send_and_read.add_argument("--smtp-port", type=str, default=25)
parser_send_and_read.add_argument("--smtp-starttls", action="store_true") parser_send_and_read.add_argument("--smtp-starttls", action="store_true")
parser_send_and_read.add_argument("--smtp-ssl", action="store_true")
parser_send_and_read.add_argument( parser_send_and_read.add_argument(
"--smtp-username", "--smtp-username",
type=str, type=str,

View File

@@ -1,9 +1,10 @@
(import ( (import
let (
lock = builtins.fromJSON (builtins.readFile ./flake.lock); let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
in fetchTarball {
fetchTarball { url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; sha256 = lock.nodes.flake-compat.locked.narHash;
sha256 = lock.nodes.flake-compat.locked.narHash; }
} )
) { src = ./.; }).shellNix { src = ./.; }
).shellNix

View File

@@ -24,79 +24,73 @@
name = "clamav"; name = "clamav";
nodes = { nodes = {
server = server = { pkgs, ... }:
{ pkgs, ... }: {
{ imports = [
imports = [ ../default.nix
../default.nix ./lib/config.nix
./lib/config.nix ];
];
virtualisation.memorySize = 1500; virtualisation.memorySize = 1500;
environment.systemPackages = with pkgs; [ netcat ]; environment.systemPackages = with pkgs; [ netcat ];
services.rsyslogd = { services.rsyslogd = {
enable = true; enable = true;
defaultConfig = '' defaultConfig = ''
*.* /dev/console *.* /dev/console
''; '';
};
services.clamav.updater.enable = lib.mkForce false;
systemd.services.old-clam = {
before = [ "clamav-daemon.service" ];
requiredBy = [ "clamav-daemon.service" ];
description = "ClamAV virus database";
preStart = ''
mkdir -m 0755 -p /var/lib/clamav
chown clamav:clamav /var/lib/clamav
'';
script = ''
cp ${blobs}/clamav/main.cvd /var/lib/clamav/
cp ${blobs}/clamav/daily.cvd /var/lib/clamav/
cp ${blobs}/clamav/bytecode.cvd /var/lib/clamav/
chown clamav:clamav /var/lib/clamav/*
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = "yes";
PrivateDevices = "yes";
};
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [
"example.com"
"example2.com"
];
virusScanning = true;
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
}; };
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
};
enableImap = true;
};
environment.etc = { services.clamav.updater.enable = lib.mkForce false;
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; systemd.services.old-clam = {
before = [ "clamav-daemon.service" ];
requiredBy = [ "clamav-daemon.service" ];
description = "ClamAV virus database";
preStart = ''
mkdir -m 0755 -p /var/lib/clamav
chown clamav:clamav /var/lib/clamav
'';
script = ''
cp ${blobs}/clamav/main.cvd /var/lib/clamav/
cp ${blobs}/clamav/daily.cvd /var/lib/clamav/
cp ${blobs}/clamav/bytecode.cvd /var/lib/clamav/
chown clamav:clamav /var/lib/clamav/*
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = "yes";
PrivateDevices = "yes";
};
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" "example2.com" ];
virusScanning = true;
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
};
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
};
enableImap = true;
};
environment.etc = {
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
};
}; };
}; client = { nodes, pkgs, ... }: let
client =
{ nodes, pkgs, ... }:
let
serverIP = nodes.server.networking.primaryIPAddress; serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress; clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" '' grep-ip = pkgs.writeScriptBin "grep-ip" ''
@@ -104,25 +98,20 @@
echo grep '${clientIP}' "$@" >&2 echo grep '${clientIP}' "$@" >&2
exec grep '${clientIP}' "$@" exec grep '${clientIP}' "$@"
''; '';
in in {
{
imports = [ imports = [
./lib/config.nix ./lib/config.nix
]; ];
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
fetchmail fetchmail msmtp procmail findutils grep-ip
msmtp
procmail
findutils
grep-ip
]; ];
environment.etc = { environment.etc = {
"root/.fetchmailrc" = { "root/.fetchmailrc" = {
text = '' text = ''
poll ${serverIP} with proto IMAP poll ${serverIP} with proto IMAP
user 'user1@example.com' there with password 'user1' is 'root' here user 'user1@example.com' there with password 'user1' is 'root' here
mda procmail mda procmail
''; '';
mode = "0700"; mode = "0700";
}; };
@@ -196,59 +185,59 @@
''; '';
}; };
}; };
}; };
testScript = '' testScript = ''
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")
# TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket. # TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket.
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]" "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 dovecot2 | grep -i error >&2") server.fail("journalctl -u dovecot2 | grep -i error >&2")
server.fail("journalctl -u dovecot2 | grep -i warning >&2") server.fail("journalctl -u dovecot2 | grep -i warning >&2")
''; '';
} }

View File

@@ -18,80 +18,74 @@
name = "external"; name = "external";
nodes = { nodes = {
server = server = { pkgs, ... }:
{ pkgs, ... }: {
{ imports = [
imports = [ ../default.nix
../default.nix ./lib/config.nix
./lib/config.nix
];
environment.systemPackages = with pkgs; [ netcat ];
virtualisation.memorySize = 1024;
services.rsyslogd = {
enable = true;
defaultConfig = ''
*.* /dev/console
'';
};
mailserver = {
enable = true;
debug.dovecot = true; # enabled for sieve script logging
fqdn = "mail.example.com";
domains = [
"example.com"
"example2.com"
];
rewriteMessageId = true;
dkimKeyBits = 1535;
dmarcReporting.enable = true;
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
};
"user2@example.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
aliases = [ "chuck@example.com" ];
};
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
"lowquota@example.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
quota = "1B";
};
};
extraVirtualAliases = {
"single-alias@example.com" = "user1@example.com";
"multi-alias@example.com" = [
"user1@example.com"
"user2@example.com"
]; ];
};
enableImap = true; environment.systemPackages = with pkgs; [ netcat ];
enableImapSsl = true;
fullTextSearch = { virtualisation.memorySize = 1024;
enable = true;
autoIndex = true; services.rsyslogd = {
# special use depends on https://github.com/NixOS/nixpkgs/pull/93201 enable = true;
autoIndexExclude = [ defaultConfig = ''
(if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk") *.* /dev/console
]; '';
enforced = "yes"; };
};
mailserver = {
enable = true;
debug = true;
fqdn = "mail.example.com";
domains = [ "example.com" "example2.com" ];
rewriteMessageId = true;
dkimKeyBits = 1535;
dmarcReporting = {
enable = true;
domain = "example.com";
organizationName = "ACME Corp";
};
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
};
"user2@example.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
aliases = [ "chuck@example.com" ];
};
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
"lowquota@example.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
quota = "1B";
};
};
extraVirtualAliases = {
"single-alias@example.com" = "user1@example.com";
"multi-alias@example.com" = [ "user1@example.com" "user2@example.com" ];
};
enableImap = true;
enableImapSsl = true;
fullTextSearch = {
enable = true;
autoIndex = true;
# special use depends on https://github.com/NixOS/nixpkgs/pull/93201
autoIndexExclude = [ (if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk") ];
enforced = "yes";
};
};
}; };
}; client = { nodes, pkgs, ... }: let
client =
{ nodes, pkgs, ... }:
let
serverIP = nodes.server.networking.primaryIPAddress; serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress; clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" '' grep-ip = pkgs.writeScriptBin "grep-ip" ''
@@ -178,36 +172,27 @@
assert needle in repr(response) assert needle in repr(response)
imap.close() imap.close()
''; '';
in in {
{
imports = [ imports = [
./lib/config.nix ./lib/config.nix
]; ];
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
fetchmail fetchmail msmtp procmail findutils grep-ip check-mail-id test-imap-spam test-imap-ham search
msmtp
procmail
findutils
grep-ip
check-mail-id
test-imap-spam
test-imap-ham
search
]; ];
environment.etc = { environment.etc = {
"root/.fetchmailrc" = { "root/.fetchmailrc" = {
text = '' text = ''
poll ${serverIP} with proto IMAP poll ${serverIP} with proto IMAP
user 'user1@example.com' there with password 'user1' is 'root' here user 'user1@example.com' there with password 'user1' is 'root' here
mda procmail mda procmail
''; '';
mode = "0700"; mode = "0700";
}; };
"root/.fetchmailRcLowQuota" = { "root/.fetchmailRcLowQuota" = {
text = '' text = ''
poll ${serverIP} with proto IMAP poll ${serverIP} with proto IMAP
user 'lowquota@example.com' there with password 'user2' is 'root' here user 'lowquota@example.com' there with password 'user2' is 'root' here
mda procmail mda procmail
''; '';
mode = "0700"; mode = "0700";
}; };
@@ -353,176 +338,176 @@
''; '';
}; };
}; };
}; };
testScript = '' testScript = ''
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")
# TODO put this blocking into the systemd units? # TODO put this blocking into the systemd units?
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /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")
with subtest("imap retrieving mail"): with subtest("imap retrieving mail"):
# 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")
with subtest("submission port send mail"): with subtest("submission port send mail"):
# send email from user2 to user1 # send 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/email1 >&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" ]')
with subtest("imap retrieving mail 2"): with subtest("imap retrieving mail 2"):
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")
with subtest("remove sensitive information on submission port"): with subtest("remove sensitive information on submission port"):
client.succeed("cat ~/mail/* >&2") client.succeed("cat ~/mail/* >&2")
## make sure our IP is _not_ in the email header ## make sure our IP is _not_ in the email header
client.fail("grep-ip ~/mail/*") client.fail("grep-ip ~/mail/*")
client.succeed("check-mail-id ~/mail/*") client.succeed("check-mail-id ~/mail/*")
with subtest("have correct fqdn as sender"): with subtest("have correct fqdn as sender"):
client.succeed("grep 'Received: from mail.example.com' ~/mail/*") client.succeed("grep 'Received: from mail.example.com' ~/mail/*")
with subtest("dkim has user-specified size"): with subtest("dkim has user-specified size"):
server.succeed( server.succeed(
"openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'" "openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'"
) )
with subtest("dkim singing, multiple domains"): with subtest("dkim singing, multiple domains"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from user2 to user1 # send email from user2 to user1
client.succeed( client.succeed(
"msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email2 >&2" "msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email2 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') 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") client.succeed("cat ~/mail/* >&2")
# make sure it is dkim signed # make sure it is dkim signed
client.succeed("grep DKIM-Signature: ~/mail/*") client.succeed("grep DKIM-Signature: ~/mail/*")
with subtest("aliases"): with subtest("aliases"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from chuck to postmaster # send email from chuck to postmaster
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 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")
with subtest("catchAlls"): with subtest("catchAlls"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from chuck to non exsitent account # send email from chuck to non exsitent account
client.succeed( client.succeed(
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol@example.com < /etc/root/email2 >&2" "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol@example.com < /etc/root/email2 >&2"
) )
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/*") client.execute("rm ~/mail/*")
# send email from user1 to chuck # send email from user1 to chuck
client.succeed( client.succeed(
"msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck@example.com < /etc/root/email2 >&2" "msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck@example.com < /etc/root/email2 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 1 when no new mail # fetchmail returns EXIT_CODE 1 when no new mail
# if this succeeds, it means that user1 recieved the mail that was intended for chuck. # if this succeeds, it means that user1 recieved the mail that was intended for chuck.
client.fail("fetchmail --nosslcertck -v") client.fail("fetchmail --nosslcertck -v")
with subtest("extraVirtualAliases"): with subtest("extraVirtualAliases"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from single-alias to user1 # send email from single-alias to user1
client.succeed( client.succeed(
"msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email4 >&2" "msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email4 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') 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/*") client.execute("rm ~/mail/*")
# send email from user1 to multi-alias (user{1,2}@example.com) # send email from user1 to multi-alias (user{1,2}@example.com)
client.succeed( client.succeed(
"msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias@example.com < /etc/root/email5 >&2" "msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias@example.com < /etc/root/email5 >&2"
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') 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("quota"): with subtest("quota"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc") client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
client.succeed( client.succeed(
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2" "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2"
) )
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.fail("fetchmail --nosslcertck -v") client.fail("fetchmail --nosslcertck -v")
with subtest("imap sieve junk trainer"): with subtest("imap sieve junk trainer"):
# send email from user2 to user1 # send 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/email1 >&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.succeed("imap-mark-spam >&2") client.succeed("imap-mark-spam >&2")
server.wait_until_succeeds("journalctl -u dovecot -u dovecot2 | grep -i rspamd-learn-spam.sh >&2") server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-spam.sh >&2")
client.succeed("imap-mark-ham >&2") client.succeed("imap-mark-ham >&2")
server.wait_until_succeeds("journalctl -u dovecot -u dovecot2 | grep -i rspamd-learn-ham.sh >&2") server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-ham.sh >&2")
with subtest("full text search and indexation"): with subtest("full text search and indexation"):
# send 2 email from user2 to user1 # send 2 email from user2 to user1
client.succeed( client.succeed(
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2" "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2"
) )
client.succeed( client.succeed(
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email7 >&2" "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email7 >&2"
) )
# give the mail server some time to process the mail # 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" ]')
# should find exactly one email containing this # should find exactly one email containing this
client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2") client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2")
# should fail because this folder is not indexed # should fail because this folder is not indexed
client.fail("search Junk a >&2") client.fail("search Junk a >&2")
# check that search really goes through the indexer # check that search really goes through the indexer
server.succeed("journalctl -u dovecot -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2") server.succeed("journalctl -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2")
# check that Junk is not indexed # check that Junk is not indexed
server.fail("journalctl -u dovecot -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2") server.fail("journalctl -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
with subtest("dmarc reporting"): with subtest("dmarc reporting"):
server.systemctl("start rspamd-dmarc-reporter.service") server.systemctl("start rspamd-dmarc-reporter.service")
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 -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2") server.fail("journalctl -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html # harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
server.fail( server.fail(
"journalctl -u dovecot -u dovecot2 | \ "journalctl -u dovecot2 | \
grep -v 'Expunged message reappeared, giving a new UID' | \ grep -v 'Expunged message reappeared, giving a new UID' | \
grep -v 'Time moved forwards' | \ grep -v 'Time moved forwards' | \
grep -i warning >&2" grep -i warning >&2"
) )
''; '';
} }

View File

@@ -30,16 +30,11 @@ let
''; '';
}; };
hashPassword = hashPassword = password: pkgs.runCommand
password: "password-${password}-hashed"
pkgs.runCommand "password-${password}-hashed" { buildInputs = [ pkgs.mkpasswd ]; inherit password; } ''
{ mkpasswd -sm bcrypt <<<"$password" > $out
buildInputs = [ pkgs.mkpasswd ]; '';
inherit password;
}
''
mkpasswd -sm bcrypt <<<"$password" > $out
'';
hashedPasswordFile = hashPassword "my-password"; hashedPasswordFile = hashPassword "my-password";
passwordFile = pkgs.writeText "password" "my-password"; passwordFile = pkgs.writeText "password" "my-password";
@@ -48,180 +43,162 @@ in
name = "internal"; name = "internal";
nodes = { nodes = {
machine = machine = { pkgs, ... }: {
{ pkgs, ... }: imports = [
{ ./../default.nix
imports = [ ./lib/config.nix
./../default.nix ];
./lib/config.nix
];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
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} $@
'') '')
] ] ++ (with pkgs; [
++ (with pkgs; [ curl
curl openssl
openssl netcat
netcat ]);
]);
mailserver = { mailserver = {
enable = true; enable = true;
fqdn = "mail.example.com"; fqdn = "mail.example.com";
domains = [ domains = [ "example.com" "domain.com" ];
"example.com" localDnsResolver = false;
"domain.com"
];
localDnsResolver = false;
loginAccounts = { loginAccounts = {
"user1@example.com" = { "user1@example.com" = {
hashedPasswordFile = hashedPasswordFile; hashedPasswordFile = hashedPasswordFile;
};
"user2@example.com" = {
hashedPasswordFile = hashedPasswordFile;
aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ];
};
"send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only";
sendOnly = true;
};
}; };
forwards = { "user2@example.com" = {
# user2@example.com is a local account and its mails are hashedPasswordFile = hashedPasswordFile;
# also forwarded to user1@example.com aliasesRegexp = [''/^user2.*@domain\.com$/''];
"user2@example.com" = "user1@example.com"; };
"send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only";
sendOnly = true;
}; };
vmailGroupName = "vmail";
vmailUID = 5000;
indexDir = "/var/lib/dovecot/indices";
enableImap = false;
}; };
forwards = {
# user2@example.com is a local account and its mails are
# also forwarded to user1@example.com
"user2@example.com" = "user1@example.com";
};
vmailGroupName = "vmail";
vmailUID = 5000;
enableImap = false;
}; };
};
}; };
testScript = testScript = ''
{ machine.start()
nodes, machine.wait_for_unit("multi-user.target")
...
}:
''
machine.start()
machine.wait_for_unit("multi-user.target")
# Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205 # Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205
with subtest("mail forwarded can are locally kept"): with subtest("mail forwarded can are locally kept"):
# A mail sent to user2@example.com via explicit TLS is in the user1@example.com mailbox # A mail sent to user2@example.com is in the user1@example.com mailbox
machine.succeed( machine.succeed(
" ".join( " ".join(
[ [
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 587", "--smtp-port 587",
"--smtp-starttls", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--imap-host localhost", "--imap-host localhost",
"--imap-username user1@example.com", "--imap-username user1@example.com",
"--from-addr user1@example.com", "--from-addr user1@example.com",
"--to-addr user2@example.com", "--to-addr user2@example.com",
"--src-password-file ${passwordFile}", "--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}", "--dst-password-file ${passwordFile}",
"--ignore-dkim-spf", "--ignore-dkim-spf",
] ]
) )
) )
# A mail sent to user2@example.com via implicit TLS is in the user2@example.com mailbox # A mail sent to user2@example.com is in the user2@example.com mailbox
machine.succeed( machine.succeed(
" ".join( " ".join(
[ [
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 465", "--smtp-port 587",
"--smtp-ssl", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--imap-host localhost", "--imap-host localhost",
"--imap-username user2@example.com", "--imap-username user2@example.com",
"--from-addr user1@example.com", "--from-addr user1@example.com",
"--to-addr user2@example.com", "--to-addr user2@example.com",
"--src-password-file ${passwordFile}", "--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}", "--dst-password-file ${passwordFile}",
"--ignore-dkim-spf", "--ignore-dkim-spf",
] ]
) )
) )
with subtest("regex email alias are received"): with subtest("regex email alias are received"):
# A mail sent to user2-regex-alias@domain.com via explicit TLS is in the user2@example.com mailbox # A mail sent to user2-regex-alias@domain.com is in the user2@example.com mailbox
machine.succeed( machine.succeed(
" ".join( " ".join(
[ [
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 587", "--smtp-port 587",
"--smtp-starttls", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--imap-host localhost", "--imap-host localhost",
"--imap-username user2@example.com", "--imap-username user2@example.com",
"--from-addr user1@example.com", "--from-addr user1@example.com",
"--to-addr user2-regex-alias@domain.com", "--to-addr user2-regex-alias@domain.com",
"--src-password-file ${passwordFile}", "--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}", "--dst-password-file ${passwordFile}",
"--ignore-dkim-spf", "--ignore-dkim-spf",
] ]
) )
) )
with subtest("user can send from regex email alias"): with subtest("user can send from regex email alias"):
# A mail sent to user1@example.com from user2-regex-alias@domain.com by # A mail sent from user2-regex-alias@domain.com, using user2@example.com credentials is received
# user2@example.com via implicit TLS is in the user1@example.com mailbox machine.succeed(
machine.succeed( " ".join(
" ".join( [
[ "mail-check send-and-read",
"mail-check send-and-read", "--smtp-port 587",
"--smtp-port 465", "--smtp-starttls",
"--smtp-ssl", "--smtp-host localhost",
"--smtp-host localhost", "--imap-host localhost",
"--imap-host localhost", "--smtp-username user2@example.com",
"--smtp-username user2@example.com", "--from-addr user2-regex-alias@domain.com",
"--from-addr user2-regex-alias@domain.com", "--to-addr user1@example.com",
"--to-addr user1@example.com", "--src-password-file ${passwordFile}",
"--src-password-file ${passwordFile}", "--dst-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}", "--ignore-dkim-spf",
"--ignore-dkim-spf", ]
] )
) )
)
with subtest("vmail gid is set correctly"): with subtest("vmail gid is set correctly"):
machine.succeed("getent group vmail | grep 5000") machine.succeed("getent group vmail | grep 5000")
with subtest("Check dovecot maildir and index locations"): with subtest("mail to send only accounts is rejected"):
# If these paths change we need a migration machine.wait_for_open_port(25)
machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.mailDirectory}/example.com/user1") # TODO put this blocking into the systemd units
machine.succeed("doveadm user -f mail user1@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/example.com/user1'") machine.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
machine.succeed(
"cat ${sendMail} | nc localhost 25 | grep -q '554 5.5.0 Error'"
)
with subtest("mail to send only accounts is rejected"): with subtest("rspamd controller serves web ui"):
machine.wait_for_open_port(25) machine.succeed(
# TODO put this blocking into the systemd units "set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'"
machine.wait_until_succeeds( )
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
machine.succeed(
"cat ${sendMail} | nc localhost 25 | grep -q '554 5.5.0 Error'"
)
with subtest("rspamd controller serves web ui"): with subtest("imap port 143 is closed and imaps is serving SSL"):
machine.succeed( machine.wait_for_closed_port(143)
"set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'" machine.wait_for_open_port(993)
) machine.succeed(
"echo | openssl s_client -connect localhost:993 | grep 'New, TLS'"
with subtest("imap port 143 is closed and imaps is serving SSL"): )
machine.wait_for_closed_port(143) '';
machine.wait_for_open_port(993)
machine.succeed(
"echo | openssl s_client -connect localhost:993 | grep 'New, TLS'"
)
'';
} }

View File

@@ -7,225 +7,212 @@ in
name = "ldap"; name = "ldap";
nodes = { nodes = {
machine = machine = { pkgs, ... }: {
{ pkgs, ... }: imports = [
{ ./../default.nix
imports = [ ./lib/config.nix
./../default.nix ];
./lib/config.nix
];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
services.openssh = { services.openssh = {
enable = true; enable = true;
settings.PermitRootLogin = "yes"; settings.PermitRootLogin = "yes";
}; };
environment.systemPackages = [ environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" '' (pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'') '')];
];
environment.etc.bind-password.text = bindPassword; environment.etc.bind-password.text = bindPassword;
services.openldap = { services.openldap = {
enable = true; enable = true;
settings = { settings = {
children = { children = {
"cn=schema".includes = [ "cn=schema".includes = [
"${pkgs.openldap}/etc/schema/core.ldif" "${pkgs.openldap}/etc/schema/core.ldif"
"${pkgs.openldap}/etc/schema/cosine.ldif" "${pkgs.openldap}/etc/schema/cosine.ldif"
"${pkgs.openldap}/etc/schema/inetorgperson.ldif" "${pkgs.openldap}/etc/schema/inetorgperson.ldif"
"${pkgs.openldap}/etc/schema/nis.ldif" "${pkgs.openldap}/etc/schema/nis.ldif"
]; ];
"olcDatabase={1}mdb" = { "olcDatabase={1}mdb" = {
attrs = { attrs = {
objectClass = [ objectClass = [
"olcDatabaseConfig" "olcDatabaseConfig"
"olcMdbConfig" "olcMdbConfig"
]; ];
olcDatabase = "{1}mdb"; olcDatabase = "{1}mdb";
olcDbDirectory = "/var/lib/openldap/example"; olcDbDirectory = "/var/lib/openldap/example";
olcSuffix = "dc=example"; olcSuffix = "dc=example";
};
}; };
}; };
}; };
declarativeContents."dc=example" = ''
dn: dc=example
objectClass: domain
dc: example
dn: cn=mail,dc=example
objectClass: organizationalRole
objectClass: simpleSecurityObject
objectClass: top
cn: mail
userPassword: ${bindPassword}
dn: ou=users,dc=example
objectClass: organizationalUnit
ou: users
dn: cn=alice,ou=users,dc=example
objectClass: inetOrgPerson
cn: alice
sn: Foo
mail: alice@example.com
userPassword: ${alicePassword}
dn: cn=bob,ou=users,dc=example
objectClass: inetOrgPerson
cn: bob
sn: Bar
mail: bob@example.com
userPassword: ${bobPassword}
'';
}; };
declarativeContents."dc=example" = ''
dn: dc=example
objectClass: domain
dc: example
mailserver = { dn: cn=mail,dc=example
enable = true; objectClass: organizationalRole
fqdn = "mail.example.com"; objectClass: simpleSecurityObject
domains = [ "example.com" ]; objectClass: top
localDnsResolver = false; cn: mail
indexDir = "/var/lib/dovecot/indices"; userPassword: ${bindPassword}
ldap = { dn: ou=users,dc=example
enable = true; objectClass: organizationalUnit
uris = [ ou: users
"ldap://"
];
bind = {
dn = "cn=mail,dc=example";
passwordFile = "/etc/bind-password";
};
searchBase = "ou=users,dc=example";
searchScope = "sub";
};
forwards = { dn: cn=alice,ou=users,dc=example
"bob_fw@example.com" = "bob@example.com"; objectClass: inetOrgPerson
}; cn: alice
sn: Foo
mail: alice@example.com
userPassword: ${alicePassword}
vmailGroupName = "vmail"; dn: cn=bob,ou=users,dc=example
vmailUID = 5000; objectClass: inetOrgPerson
cn: bob
enableImap = false; sn: Bar
}; mail: bob@example.com
userPassword: ${bobPassword}
'';
}; };
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" ];
localDnsResolver = false;
ldap = {
enable = true;
uris = [
"ldap://"
];
bind = {
dn = "cn=mail,dc=example";
passwordFile = "/etc/bind-password";
};
searchBase = "ou=users,dc=example";
searchScope = "sub";
};
forwards = {
"bob_fw@example.com" = "bob@example.com";
};
vmailGroupName = "vmail";
vmailUID = 5000;
enableImap = false;
};
};
}; };
testScript = testScript = ''
{ import sys
nodes, import re
...
}:
''
import sys
import re
machine.start() machine.start()
machine.wait_for_unit("multi-user.target") machine.wait_for_unit("multi-user.target")
# 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 achived 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 = re.match('.* =.*ldap:(.*)', conf).group(1) ldap_table_path = re.match('.* =.*ldap:(.*)', conf).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
except AssertionError: except AssertionError:
print(f"Expected {conf} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr) print(f"Expected {conf} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr)
raise raise
with subtest("Test postmap lookups"): with subtest("Test postmap lookups"):
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice@example.com") test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice@example.com")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice@example.com") test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice@example.com")
test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob@example.com") test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob@example.com")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob@example.com") 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")
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'") machine.succeed("ls -l /run/dovecot2/dovecot-ldap.conf.ext | grep -e '-rw------- 1 root root'")
with subtest("Test account/mail address binding via explicit TLS"): with subtest("Test account/mail address binding"):
machine.fail(" ".join([ machine.fail(" ".join([
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 587", "--smtp-port 587",
"--smtp-starttls", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--smtp-username alice@example.com", "--smtp-username alice@example.com",
"--imap-host localhost", "--imap-host localhost",
"--imap-username bob@example.com", "--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@example.com'") machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'")
with subtest("Test mail delivery via implicit TLS"): with subtest("Test mail delivery"):
machine.succeed(" ".join([ machine.succeed(" ".join([
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 465", "--smtp-port 587",
"--smtp-ssl", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--smtp-username alice@example.com", "--smtp-username alice@example.com",
"--imap-host localhost", "--imap-host localhost",
"--imap-username bob@example.com", "--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}')",
"--dst-password-file <(echo '${bobPassword}')", "--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf" "--ignore-dkim-spf"
])) ]))
with subtest("Test mail forwarding via explicit TLS works"): with subtest("Test mail forwarding works"):
machine.succeed(" ".join([ machine.succeed(" ".join([
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 587", "--smtp-port 587",
"--smtp-starttls", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--smtp-username alice@example.com", "--smtp-username alice@example.com",
"--imap-host localhost", "--imap-host localhost",
"--imap-username bob@example.com", "--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}')",
"--dst-password-file <(echo '${bobPassword}')", "--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf" "--ignore-dkim-spf"
])) ]))
with subtest("Test cannot send mail via implicit TLS from forwarded address"): with subtest("Test cannot send mail from forwarded address"):
machine.fail(" ".join([ machine.fail(" ".join([
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 465", "--smtp-port 587",
"--smtp-ssl", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--smtp-username bob@example.com", "--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",
"--to-addr alice@example.com", "--to-addr alice@example.com",
"--src-password-file <(echo '${bobPassword}')", "--src-password-file <(echo '${bobPassword}')",
"--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@example.com'") machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob@example.com'")
with subtest("Check dovecot mail and index locations"): '';
# If these paths change we need a migration
machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.mailDirectory}/ldap/bob@example.com")
machine.succeed("doveadm user -f mail bob@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/ldap/bob@example.com'")
'';
} }

View File

@@ -1,28 +1,3 @@
{ {
lib, security.dhparams.defaultBitSize = 2048; # minimum size required by dovecot
...
}:
{
# Testing eval failures that result from stateVersion assertion is out of scope
mailserver.stateVersion = 999;
# Keep testing submission with explicit TLS
mailserver.enableSubmission = true;
# Enable second CPU core
virtualisation.cores = lib.mkDefault 2;
services.rspamd = {
# Don't make tests block on DNS requests that will never succeed
locals."options.inc".text = ''
dns {
nameservers = ["127.0.0.1"];
timeout = 0.0s;
retransmits = 0;
}
'';
# Relax `local_addrs` definition to default for tests, so mail doesn't get flagged as spam
overrides."options.inc".enable = false;
};
} }

27
tests/minimal.nix Normal file
View File

@@ -0,0 +1,27 @@
# nixos-mailserver: a simple mail server
# Copyright (C) 2016-2018 Robin Raymond
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
name = "minimal";
nodes.machine = {
imports = [ ./../default.nix ];
};
testScript = ''
machine.wait_for_unit("multi-user.target");
'';
}

View File

@@ -1,33 +1,22 @@
# This tests is used to test features requiring several mail domains. # This tests is used to test features requiring several mail domains.
{ {
lib,
pkgs, pkgs,
... ...
}: }:
let let
hashPassword = hashPassword = password: pkgs.runCommand
password: "password-${password}-hashed"
pkgs.runCommand "password-${password}-hashed" { buildInputs = [ pkgs.mkpasswd ]; inherit password; }
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
'' ''
mkpasswd -sm bcrypt <<<"$password" > $out mkpasswd -sm bcrypt <<<"$password" > $out
''; '';
password = pkgs.writeText "password" "password"; password = pkgs.writeText "password" "password";
domainGenerator = domainGenerator = domain: { pkgs, ... }: {
domain: imports = [../default.nix];
{ pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
environment.systemPackages = with pkgs; [ netcat ]; environment.systemPackages = with pkgs; [ netcat ];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
mailserver = { mailserver = {
@@ -45,14 +34,8 @@ let
}; };
services.dnsmasq = { services.dnsmasq = {
enable = true; enable = true;
settings.mx-host = [ settings.mx-host = [ "domain1.com,domain1,10" "domain2.com,domain2,10" ];
"domain1.com,domain1,10"
"domain2.com,domain2,10"
];
}; };
# breaks the test, due to running into DNS timeouts
services.postfix-tlspol.configurePostfix = lib.mkForce false;
}; };
in in
@@ -61,34 +44,23 @@ in
name = "multiple"; name = "multiple";
nodes = { nodes = {
domain1 = domain1 = {...}: {
{ ... }: imports = [
{ ../default.nix
imports = [ (domainGenerator "domain1.com")
../default.nix ];
(domainGenerator "domain1.com") mailserver.forwards = {
]; "non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"];
mailserver.forwards = { "non@domain1.com" = ["user@domain2.com" "user@domain1.com"];
"non-local@domain1.com" = [
"user@domain2.com"
"user@domain1.com"
];
"non@domain1.com" = [
"user@domain2.com"
"user@domain1.com"
];
};
}; };
};
domain2 = domainGenerator "domain2.com"; domain2 = domainGenerator "domain2.com";
client = client = { pkgs, ... }: {
{ pkgs, ... }: environment.systemPackages = [
{ (pkgs.writeScriptBin "mail-check" ''
environment.systemPackages = [ ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
(pkgs.writeScriptBin "mail-check" '' '')];
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ };
'')
];
};
}; };
testScript = '' testScript = ''
start_all() start_all()
@@ -104,14 +76,14 @@ in
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
# user@domain1.com sends a mail to user@domain2.com via explicit TLS # user@domain1.com sends a mail to user@domain2.com
client.succeed( client.succeed(
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf" "mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
) )
# Send a mail to the address forwarded via implicit TLS and check it is in the recipient mailbox # Send a mail to the address forwarded and check it is in the recipient mailbox
client.succeed( client.succeed(
"mail-check send-and-read --smtp-port 465 --smtp-ssl --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf" "mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
) )
''; '';
} }