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

View File

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

View File

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

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
``vmailGoupName``).
If you enabled ``enableManageSieve`` then you also may want to backup
``/var/sieve`` or whatever you have specified as ``sieveDirectory``.
The same considerations regarding file ownership apply as for the
Maildir.
To backup spam and ham training data, backup ``/var/lib/redis-rspamd``.
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever
you specified as ``dkimKeyDirectory``). If you should lose those dont
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
$ 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
setup-guide
advanced-configurations
howto-develop
faq
release-notes
options
migrations
.. toctree::
:maxdepth: 1
:caption: Features
fts
ldap
srs
.. toctree::
:maxdepth: 0
:caption: How-to
backup-guide
add-radicale
add-roundcube
rspamd-tuning
fts
flakes
autodiscovery
ldap
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
=============
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
-----------

View File

@@ -63,16 +63,15 @@ common ones.
imports = [
(builtins.fetchTarball {
# Pick a release version you are interested in and set its hash, e.g.
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.11/nixos-mailserver-nixos-25.11.tar.gz";
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.05/nixos-mailserver-nixos-25.05.tar.gz";
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command:
# release="nixos-25.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";
})
];
mailserver = {
enable = true;
stateVersion = 3;
fqdn = "mail.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
your setup, but if you followed the steps closely then everything should
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": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
@@ -43,11 +43,11 @@
]
},
"locked": {
"lastModified": 1763319842,
"narHash": "sha256-YG19IyrTdnVn0l3DvcUYm85u3PaqBt6tI6VvolcuHnA=",
"lastModified": 1742649964,
"narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "7275fa67fbbb75891c16d9dee7d88e58aea2d761",
"rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82",
"type": "github"
},
"original": {
@@ -79,16 +79,32 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1764020296,
"narHash": "sha256-6zddwDs2n+n01l+1TG6PlyokDdXzu/oBmEejcH5L5+A=",
"lastModified": 1747179050,
"narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a320ce8e6e2cc6b4397eef214d202a50a4583829",
"rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"type": "github"
},
"original": {
"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",
"type": "github"
}
@@ -98,7 +114,8 @@
"blobs": "blobs",
"flake-compat": "flake-compat",
"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.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 = {
url = "gitlab:simple-nixos-mailserver/blobs";
flake = false;
};
};
outputs =
{
self,
blobs,
git-hooks,
nixpkgs,
...
}:
let
lib = nixpkgs.lib;
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
releases = [
{
name = "nixos-25.11";
nixpkgs = nixpkgs;
pkgs = nixpkgs.legacyPackages.${system};
}
];
testNames = [
"clamav"
"external"
"internal"
"ldap"
"multiple"
];
outputs = { self, blobs, git-hooks, nixpkgs, nixpkgs-25_05, ... }: let
lib = nixpkgs.lib;
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
releases = [
{
name = "unstable";
nixpkgs = nixpkgs;
pkgs = nixpkgs.legacyPackages.${system};
}
{
name = "25.05";
nixpkgs = nixpkgs-25_05;
pkgs = nixpkgs-25_05.legacyPackages.${system};
}
];
testNames = [
"clamav"
"external"
"internal"
"ldap"
"multiple"
];
genTest =
testName: release:
let
pkgs = release.pkgs;
nixos-lib = import (release.nixpkgs + "/nixos/lib") {
inherit (pkgs) lib;
};
in
{
name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}";
value = nixos-lib.runTest {
hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ];
_module.args = { inherit blobs; };
extraBaseModules.imports = [ ./default.nix ];
};
};
# Generate an attribute set such as
# {
# external-unstable = <derivation>;
# external-21_05 = <derivation>;
# ...
# }
allTests = lib.listToAttrs (lib.flatten (map (t: map (r: genTest t r) releases) testNames));
mailserverModule = import ./.;
# Generate a MarkDown file describing the options of the NixOS mailserver module
optionsDoc =
let
eval = lib.evalModules {
modules = [
mailserverModule
{
_module.check = false;
mailserver = {
fqdn = "mx.example.com";
systemDomain = "example.com";
domains = [
"example.com"
];
};
}
];
};
options = builtins.toFile "options.json" (
builtins.toJSON (
lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") (
lib.optionAttrSetToDocList eval.options
)
)
);
in
pkgs.runCommand "options.md" { buildInputs = [ pkgs.python3Minimal ]; } ''
echo "Generating options.md from ${options}"
python ${./scripts/generate-options.py} ${options} > $out
echo $out
'';
documentation = pkgs.stdenv.mkDerivation {
name = "documentation";
src = lib.sourceByRegex ./docs [
"logo\\.png"
"conf\\.py"
"Makefile"
".*\\.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
'';
genTest = testName: release: let
pkgs = release.pkgs;
nixos-lib = import (release.nixpkgs + "/nixos/lib") {
inherit (pkgs) lib;
};
in {
name = "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}";
value = nixos-lib.runTest {
hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ];
_module.args = { inherit blobs; };
extraBaseModules.imports = [ ./default.nix ];
};
};
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;
# 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";
domains = [
"example.com"
];
dmarcReporting = {
organizationName = "Example Corp";
domain = "example.com";
};
};
rstcheck = {
enable = true;
package = pkgs.rstcheckWithSphinx;
entry = lib.getExe pkgs.rstcheckWithSphinx;
files = "\\.rst$";
}
];
};
options = builtins.toFile "options.json" (builtins.toJSON
(lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver")
(lib.optionAttrSetToDocList eval.options)));
in pkgs.runCommand "options.md" { buildInputs = [pkgs.python3Minimal]; } ''
echo "Generating options.md from ${options}"
python ${./scripts/generate-options.py} ${options} > $out
echo $out
'';
documentation = pkgs.stdenv.mkDerivation {
name = "documentation";
src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"];
buildInputs = [(
pkgs.python3.withPackages (p: with p; [
sphinx
sphinx_rtd_theme
myst-parser
linkify-it-py
])
)];
buildPhase = ''
cp ${optionsDoc} options.md
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
unset SOURCE_DATE_EPOCH
make html
'';
installPhase = ''
cp -Tr _build/html $out
'';
};
in {
nixosModules = rec {
mailserver = mailserverModule;
default = mailserver;
};
nixosModule = self.nixosModules.default; # compatibility
hydraJobs.${system} = allTests // {
inherit documentation;
inherit (self.checks.${system}) pre-commit;
};
checks.${system} = allTests // {
pre-commit = git-hooks.lib.${system}.run {
src = ./.;
hooks = {
# docs
markdownlint = {
enable = true;
settings.configuration = {
# Max line length, doesn't seem to correclty account for lines containing links
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
MD013 = false;
};
};
rstcheck = {
enable = true;
package = pkgs.rstcheckWithSphinx;
entry = lib.getExe pkgs.rstcheckWithSphinx;
files = "\\.rst$";
};
# nix
deadnix.enable = true;
nixfmt-rfc-style.enable = true;
# nix
deadnix.enable = true;
# python
pyright.enable = true;
ruff = {
enable = true;
args = [
"--extend-select"
"I"
];
};
ruff-format.enable = true;
# python
pyright.enable = true;
ruff = {
enable = true;
args = [
"--extend-select"
"I"
];
};
ruff-format.enable = true;
# scripts
shellcheck.enable = true;
# scripts
shellcheck.enable = true;
# sieve
check-sieve = {
enable = true;
package = pkgs.check-sieve;
entry = lib.getExe pkgs.check-sieve;
files = "\\.sieve$";
};
# sieve
check-sieve = {
enable = true;
package = pkgs.check-sieve;
entry = lib.getExe pkgs.check-sieve;
files = "\\.sieve$";
};
};
};
packages.${system} = {
inherit optionsDoc documentation;
};
devShells.${system}.default = pkgs.mkShellNoCC {
inputsFrom = [ documentation ];
packages =
with pkgs;
[
glab
]
++ self.checks.${system}.pre-commit.enabledPackages;
shellHook = self.checks.${system}.pre-commit.shellHook;
};
devShell.${system} = self.devShells.${system}.default; # compatibility
formatter.${system} = pkgs.nixfmt-tree;
};
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,
lib,
...
}:
let
mailserverRelease = "25.11";
nixpkgsRelease = lib.trivial.release;
releaseMismatch =
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease;
in
{
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";
}
]
);
assertions = lib.optionals config.mailserver.ldap.enable [
{
assertion = config.mailserver.loginAccounts == {};
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts";
}
{
assertion = config.mailserver.extraVirtualAliases == {};
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
}
] ++ lib.optionals (config.mailserver.enable && config.mailserver.certificateScheme != "acme") [
{
assertion = config.mailserver.acmeCertificateName == config.mailserver.fqdn;
message = "When the certificate scheme is not 'acme' (mailserver.certificateScheme != \"acme\"), it is not possible to define mailserver.acmeCertificateName";
}
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,12 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
@@ -31,127 +26,94 @@ let
rspamdUser = config.services.rspamd.user;
rspamdGroup = config.services.rspamd.group;
createDkimKeypair =
domain:
let
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
in
pkgs.writeShellScript "dkim-keygen-${domain}" ''
if [ ! -f "${privateKey}" ]
then
${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \
--domain "${domain}" \
--selector "${cfg.dkimSelector}" \
--type "${cfg.dkimKeyType}" \
--bits ${toString cfg.dkimKeyBits} \
--privkey "${privateKey}" > "${publicKey}"
chmod 0644 "${publicKey}"
echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
fi
'';
dkimDomains = lib.unique (cfg.domains ++ (lib.optionals cfg.srs.enable [ cfg.srs.domain ]));
createDkimKeypair = domain: let
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
in pkgs.writeShellScript "dkim-keygen-${domain}" ''
if [ ! -f "${privateKey}" ]
then
${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \
--domain "${domain}" \
--selector "${cfg.dkimSelector}" \
--type "${cfg.dkimKeyType}" \
--bits ${toString cfg.dkimKeyBits} \
--privkey "${privateKey}" > "${publicKey}"
chmod 0644 "${publicKey}"
echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
fi
'';
in
{
config = lib.mkIf cfg.enable {
config = with cfg; lib.mkIf enable {
environment.systemPackages = lib.mkBefore [
(pkgs.runCommand "rspamc-wrapped"
{
nativeBuildInputs = with pkgs; [ makeWrapper ];
}
''
makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \
--add-flags "-h /run/rspamd/worker-controller.sock"
''
)
(pkgs.runCommand "rspamc-wrapped" {
nativeBuildInputs = with pkgs; [ makeWrapper ];
}''
makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \
--add-flags "-h /run/rspamd/worker-controller.sock"
'')
];
services.rspamd = {
enable = true;
debug = cfg.debug.rspamd;
inherit debug;
locals = {
"milter_headers.conf" = {
text = ''
extended_spam_headers = true;
'';
};
"redis.conf" = {
text = ''
servers = "${
if cfg.redis.port == null then
cfg.redis.address
else
"${cfg.redis.address}:${toString cfg.redis.port}"
}";
''
+ (lib.optionalString (cfg.redis.password != null) ''
password = "${cfg.redis.password}";
'');
};
"classifier-bayes.conf" = {
text = ''
cache {
backend = "redis";
}
'';
};
"antivirus.conf" = lib.mkIf cfg.virusScanning {
text = ''
clamav {
action = "reject";
symbol = "CLAM_VIRUS";
type = "clamav";
log_clean = true;
servers = "/run/clamav/clamd.ctl";
scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all
}
'';
};
"dkim_signing.conf" = {
text = ''
"milter_headers.conf" = { text = ''
extended_spam_headers = true;
''; };
"redis.conf" = { text = ''
servers = "${if cfg.redis.port == null
then
cfg.redis.address
else
"${cfg.redis.address}:${toString cfg.redis.port}"}";
'' + (lib.optionalString (cfg.redis.password != null) ''
password = "${cfg.redis.password}";
''); };
"classifier-bayes.conf" = { text = ''
cache {
backend = "redis";
}
''; };
"antivirus.conf" = lib.mkIf cfg.virusScanning { text = ''
clamav {
action = "reject";
symbol = "CLAM_VIRUS";
type = "clamav";
log_clean = true;
servers = "/run/clamav/clamd.ctl";
scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all
}
''; };
"dkim_signing.conf" = { text = ''
enabled = ${lib.boolToString cfg.dkimSigning};
path = "${cfg.dkimKeyDirectory}/$domain.$selector.key";
selector = "${cfg.dkimSelector}";
# Allow for usernames w/o domain part
allow_username_mismatch = true;
# Don't normalize DKIM key selection for subdomains
use_esld = false;
'';
};
"dmarc.conf" = {
text = ''
${lib.optionalString cfg.dmarcReporting.enable ''
allow_username_mismatch = true
''; };
"dmarc.conf" = { text = ''
${lib.optionalString cfg.dmarcReporting.enable ''
reporting {
enabled = true;
email = "noreply-dmarc@${cfg.systemDomain}";
domain = "${cfg.systemDomain}";
org_name = "${cfg.systemName}";
from_name = "${cfg.systemName}";
msgid_from = "${cfg.systemDomain}";
${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) ''
email = "${cfg.dmarcReporting.email}";
domain = "${cfg.dmarcReporting.domain}";
org_name = "${cfg.dmarcReporting.organizationName}";
from_name = "${cfg.dmarcReporting.fromName}";
msgid_from = "${cfg.dmarcReporting.domain}";
${lib.optionalString (cfg.dmarcReporting.excludeDomains != []) ''
exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains};
''}
}''}
'';
};
};
overrides = {
"options.inc" = {
text = ''
local_addrs = [::1/128, 127.0.0.0/8]
'';
};
''; };
};
workers.rspamd_proxy = {
type = "rspamd_proxy";
bindSockets = [
{
socket = "/run/rspamd/rspamd-milter.sock";
mode = "0664";
}
];
bindSockets = [{
socket = "/run/rspamd/rspamd-milter.sock";
mode = "0664";
}];
count = 1; # Do not spawn too many processes of this type
extraConfig = ''
milter = yes; # Enable milter mode
@@ -166,13 +128,11 @@ in
workers.controller = {
type = "controller";
count = 1;
bindSockets = [
{
socket = "/run/rspamd/worker-controller.sock";
mode = "0666";
}
];
includes = [ ];
bindSockets = [{
socket = "/run/rspamd/worker-controller.sock";
mode = "0666";
}];
includes = [];
extraConfig = ''
static_dir = "''${WWWDIR}"; # Serve the web UI static assets
'';
@@ -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" = {
"${cfg.dkimKeyDirectory}" = {
@@ -205,26 +165,24 @@ in
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
}
(lib.optionalAttrs cfg.dkimSigning {
ExecStartPre = map createDkimKeypair dkimDomains;
ExecStartPre = map createDkimKeypair cfg.domains;
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
# default behaviour when called without a date.
# https://github.com/rspamd/rspamd/issues/4062
script = toString [
(lib.getExe' pkgs.rspamd "rspamadm")
"dmarc_report"
"$(date -d 'yesterday' '+%Y%m%d')"
];
script = ''
${pkgs.rspamd}/bin/rspamadm dmarc_report $(date -d "yesterday" "+%Y%m%d")
'';
serviceConfig = {
User = "${config.services.rspamd.user}";
Group = "${config.services.rspamd.group}";
AmbientCapabilities = [ ];
AmbientCapabilities = [];
CapabilityBoundingSet = "";
DevicePolicy = "closed";
IPAddressAllow = "localhost";
@@ -245,17 +203,10 @@ in
ProcSubset = "pid";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SupplementaryGroups = lib.optionals cfg.redis.configureLocally [
config.services.redis.servers.rspamd.group
];
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
@@ -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";
wantedBy = [
"timers.target"
@@ -286,3 +237,4 @@ in
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
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
certificatesDeps =
if cfg.certificateScheme == "manual" then
[ ]
[]
else if cfg.certificateScheme == "selfsigned" then
[ "mailserver-selfsigned-certificate.service" ]
else
[ "acme-finished-${cfg.fqdn}.target" ];
in
{
config = lib.mkIf cfg.enable {
config = with cfg; lib.mkIf enable {
# Create self signed certificate
systemd.services.mailserver-selfsigned-certificate =
lib.mkIf (cfg.certificateScheme == "selfsigned")
{
after = [ "local-fs.target" ];
script = ''
# Create certificates if they do not exist yet
dir="${cfg.certificateDirectory}"
fqdn="${cfg.fqdn}"
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
key="$dir/key-${cfg.fqdn}.pem";
cert="$dir/cert-${cfg.fqdn}.pem";
systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == "selfsigned") {
after = [ "local-fs.target" ];
script = ''
# Create certificates if they do not exist yet
dir="${cfg.certificateDirectory}"
fqdn="${cfg.fqdn}"
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
key="$dir/key-${cfg.fqdn}.pem";
cert="$dir/cert-${cfg.fqdn}.pem";
if [[ ! -f $key || ! -f $cert ]]; then
mkdir -p "${cfg.certificateDirectory}"
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) &&
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \
-days 3650 -out "$cert"
fi
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = true;
};
};
if [[ ! -f $key || ! -f $cert ]]; then
mkdir -p "${cfg.certificateDirectory}"
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) &&
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \
-days 3650 -out "$cert"
fi
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = true;
};
};
# Create maildir folder before dovecot startup
systemd.services.dovecot = {
systemd.services.dovecot2 = {
wants = certificatesDeps;
after = certificatesDeps;
preStart =
let
directories = lib.strings.escapeShellArgs (
[ cfg.mailDirectory ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir
);
in
''
# Create mail directory and set permissions. See
# <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>.
# Prevent world-readable paths, even temporarily.
umask 007
mkdir -p ${directories}
chgrp "${cfg.vmailGroupName}" ${directories}
chmod 02770 ${directories}
'';
preStart = let
directories = lib.strings.escapeShellArgs (
[ mailDirectory ]
++ lib.optional (cfg.indexDir != null) cfg.indexDir
);
in ''
# Create mail directory and set permissions. See
# <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>.
# Prevent world-readable paths, even temporarily.
umask 007
mkdir -p ${directories}
chgrp "${vmailGroupName}" ${directories}
chmod 02770 ${directories}
'';
};
# Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work
systemd.services.postfix = {
wants = certificatesDeps;
after = [
"dovecot.service"
]
++ lib.optional cfg.dkimSigning "rspamd.service"
++ certificatesDeps;
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service";
after = [ "dovecot2.service" ]
++ lib.optional cfg.dkimSigning "rspamd.service"
++ certificatesDeps;
requires = [ "dovecot2.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
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
{ config, pkgs, lib, ... }:
with config.mailserver;
@@ -43,6 +28,7 @@ let
group = vmailGroupName;
};
virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" ''
#!${pkgs.stdenv.shell}
@@ -60,54 +46,45 @@ let
# Copy user's sieve script to the correct location (if it exists). If it
# is null, remove the file.
${lib.concatMapStringsSep "\n" (
{ name, sieveScript }:
if lib.isString sieveScript then
''
if (! test -d "${sieveDirectory}/${name}"); then
mkdir -p "${sieveDirectory}/${name}"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
chmod 770 "${sieveDirectory}/${name}"
fi
cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve"
${sieveScript}
EOF
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
''
else
''
if (test -f "${sieveDirectory}/${name}/default.sieve"); then
rm "${sieveDirectory}/${name}/default.sieve"
fi
if (test -f "${sieveDirectory}/${name}.svbin"); then
rm "${sieveDirectory}/${name}/default.svbin"
fi
''
) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))}
${lib.concatMapStringsSep "\n" ({ name, sieveScript }:
if lib.isString sieveScript then ''
if (! test -d "${sieveDirectory}/${name}"); then
mkdir -p "${sieveDirectory}/${name}"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
chmod 770 "${sieveDirectory}/${name}"
fi
cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve"
${sieveScript}
EOF
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
'' else ''
if (test -f "${sieveDirectory}/${name}/default.sieve"); then
rm "${sieveDirectory}/${name}/default.sieve"
fi
if (test -f "${sieveDirectory}/${name}.svbin"); then
rm "${sieveDirectory}/${name}/default.svbin"
fi
'') (map (user: { inherit (user) name sieveScript; })
(lib.attrValues loginAccounts))}
'';
in
{
in {
config = lib.mkIf enable {
# assert that all accounts provide a password
assertions = map (acct: {
assertion = acct.hashedPassword != null || acct.hashedPasswordFile != null;
assertions = (map (acct: {
assertion = (acct.hashedPassword != null || acct.hashedPasswordFile != null);
message = "${acct.name} must provide either a hashed password or a password hash file";
}) (lib.attrValues loginAccounts);
}) (lib.attrValues loginAccounts));
# warn for accounts that specify both password and file
warnings =
map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
(
lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) (
lib.attrValues loginAccounts
)
);
warnings = (map
(acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
(lib.filter
(acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null))
(lib.attrValues loginAccounts)));
# set the vmail gid to a specific value
users.groups = {
"${vmailGroupName}" = {
gid = vmailUID;
};
"${vmailGroupName}" = { gid = vmailUID; };
};
# define all users
@@ -117,7 +94,7 @@ in
systemd.services.activate-virtual-mail-users = {
wantedBy = [ "multi-user.target" ];
before = [ "dovecot.service" ];
before = [ "dovecot2.service" ];
serviceConfig = {
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.certificate",
"mailserver.dkim",
"mailserver.srs",
"mailserver.dmarcReporting",
"mailserver.fullTextSearch",
"mailserver.redis",
@@ -91,9 +90,7 @@ def print_option(option):
key=option["name"],
description=description or "",
type=f"- type: {md_literal(option['type'])}",
default=render_option_value(option, "defaultText")
if "defaultText" in option
else render_option_value(option, "default"),
default=render_option_value(option, "default"),
example=render_option_value(option, "example"),
)
)

View File

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

View File

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

View File

@@ -24,79 +24,73 @@
name = "clamav";
nodes = {
server =
{ pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
server = { pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
virtualisation.memorySize = 1500;
virtualisation.memorySize = 1500;
environment.systemPackages = with pkgs; [ netcat ];
environment.systemPackages = with pkgs; [ netcat ];
services.rsyslogd = {
enable = true;
defaultConfig = ''
*.* /dev/console
'';
};
services.clamav.updater.enable = lib.mkForce false;
systemd.services.old-clam = {
before = [ "clamav-daemon.service" ];
requiredBy = [ "clamav-daemon.service" ];
description = "ClamAV virus database";
preStart = ''
mkdir -m 0755 -p /var/lib/clamav
chown clamav:clamav /var/lib/clamav
'';
script = ''
cp ${blobs}/clamav/main.cvd /var/lib/clamav/
cp ${blobs}/clamav/daily.cvd /var/lib/clamav/
cp ${blobs}/clamav/bytecode.cvd /var/lib/clamav/
chown clamav:clamav /var/lib/clamav/*
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = "yes";
PrivateDevices = "yes";
};
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [
"example.com"
"example2.com"
];
virusScanning = true;
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
services.rsyslogd = {
enable = true;
defaultConfig = ''
*.* /dev/console
'';
};
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
};
enableImap = true;
};
environment.etc = {
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
services.clamav.updater.enable = lib.mkForce false;
systemd.services.old-clam = {
before = [ "clamav-daemon.service" ];
requiredBy = [ "clamav-daemon.service" ];
description = "ClamAV virus database";
preStart = ''
mkdir -m 0755 -p /var/lib/clamav
chown clamav:clamav /var/lib/clamav
'';
script = ''
cp ${blobs}/clamav/main.cvd /var/lib/clamav/
cp ${blobs}/clamav/daily.cvd /var/lib/clamav/
cp ${blobs}/clamav/bytecode.cvd /var/lib/clamav/
chown clamav:clamav /var/lib/clamav/*
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = "yes";
PrivateDevices = "yes";
};
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" "example2.com" ];
virusScanning = true;
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
};
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
};
enableImap = true;
};
environment.etc = {
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
};
};
};
client =
{ nodes, pkgs, ... }:
let
client = { nodes, pkgs, ... }: let
serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" ''
@@ -104,25 +98,20 @@
echo grep '${clientIP}' "$@" >&2
exec grep '${clientIP}' "$@"
'';
in
{
in {
imports = [
./lib/config.nix
./lib/config.nix
];
environment.systemPackages = with pkgs; [
fetchmail
msmtp
procmail
findutils
grep-ip
fetchmail msmtp procmail findutils grep-ip
];
environment.etc = {
"root/.fetchmailrc" = {
text = ''
poll ${serverIP} with proto IMAP
user 'user1@example.com' there with password 'user1' is 'root' here
mda procmail
poll ${serverIP} with proto IMAP
user 'user1@example.com' there with password 'user1' is 'root' here
mda procmail
'';
mode = "0700";
};
@@ -196,59 +185,59 @@
'';
};
};
};
};
testScript = ''
start_all()
start_all()
server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target")
server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target")
# TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket.
server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]"
)
# TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket.
server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]"
)
client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail")
client.succeed("ls -la ~/ >&2")
client.succeed("cat ~/.fetchmailrc >&2")
client.succeed("cat ~/.procmailrc >&2")
client.succeed("cat ~/.msmtprc >&2")
client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail")
client.succeed("ls -la ~/ >&2")
client.succeed("cat ~/.fetchmailrc >&2")
client.succeed("cat ~/.procmailrc >&2")
client.succeed("cat ~/.msmtprc >&2")
# fetchmail returns EXIT_CODE 1 when no new mail
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2")
# fetchmail returns EXIT_CODE 1 when no new mail
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2")
# Verify that mail can be sent and received before testing virus scanner
client.execute("rm ~/mail/*")
client.succeed("msmtp -a user2 user1@example.com < /etc/root/safe-email >&2")
# give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
client.execute("rm ~/mail/*")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v >&2")
client.execute("rm ~/mail/*")
# Verify that mail can be sent and received before testing virus scanner
client.execute("rm ~/mail/*")
client.succeed("msmtp -a user2 user1@example.com < /etc/root/safe-email >&2")
# give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
client.execute("rm ~/mail/*")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v >&2")
client.execute("rm ~/mail/*")
with subtest("virus scan file"):
server.succeed(
'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2'
)
with subtest("virus scan file"):
server.succeed(
'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2'
)
with subtest("virus scan email"):
client.succeed(
'set +o pipefail; msmtp -a user2 user1@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2'
)
server.succeed("journalctl -u rspamd | grep -i eicar")
# give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
with subtest("virus scan email"):
client.succeed(
'set +o pipefail; msmtp -a user2 user1@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2'
)
server.succeed("journalctl -u rspamd | grep -i eicar")
# give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&2")
server.fail("journalctl -u dovecot2 | grep -i error >&2")
server.fail("journalctl -u dovecot2 | grep -i warning >&2")
'';
with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&2")
server.fail("journalctl -u dovecot2 | grep -i error >&2")
server.fail("journalctl -u dovecot2 | grep -i warning >&2")
'';
}

View File

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

View File

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

View File

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

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.
{
lib,
pkgs,
...
}:
let
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
hashPassword = password: pkgs.runCommand
"password-${password}-hashed"
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; }
''
mkpasswd -sm bcrypt <<<"$password" > $out
'';
password = pkgs.writeText "password" "password";
password = pkgs.writeText "password" "password";
domainGenerator =
domain:
{ pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
domainGenerator = domain: { pkgs, ... }: {
imports = [../default.nix];
environment.systemPackages = with pkgs; [ netcat ];
virtualisation.memorySize = 1024;
mailserver = {
@@ -45,14 +34,8 @@ let
};
services.dnsmasq = {
enable = true;
settings.mx-host = [
"domain1.com,domain1,10"
"domain2.com,domain2,10"
];
settings.mx-host = [ "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
@@ -61,34 +44,23 @@ in
name = "multiple";
nodes = {
domain1 =
{ ... }:
{
imports = [
../default.nix
(domainGenerator "domain1.com")
];
mailserver.forwards = {
"non-local@domain1.com" = [
"user@domain2.com"
"user@domain1.com"
];
"non@domain1.com" = [
"user@domain2.com"
"user@domain1.com"
];
};
domain1 = {...}: {
imports = [
../default.nix
(domainGenerator "domain1.com")
];
mailserver.forwards = {
"non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"];
"non@domain1.com" = ["user@domain2.com" "user@domain1.com"];
};
};
domain2 = domainGenerator "domain2.com";
client =
{ pkgs, ... }:
{
environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')
];
};
client = { pkgs, ... }: {
environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')];
};
};
testScript = ''
start_all()
@@ -104,14 +76,14 @@ in
"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(
"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(
"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"
)
'';
}