190 Commits

Author SHA1 Message Date
Jakub Skokan af9fc4c519 Allow TLSv1 for compatibility with older devices 2026-06-05 11:31:35 +02:00
Martin Weinelt 6c11ff592e flake.nix: switch nixpkgs to nixos-26.05-small 2026-05-24 21:38:33 +02:00
Martin Weinelt c894d1816d docs: set release version 2026-05-24 21:30:53 +02:00
Martin Weinelt 04094b9a1c Merge branch '26.05' into 'main'
26.05 Release

See merge request simple-nixos-mailserver/nixos-mailserver!523
2026-05-24 19:29:19 +00:00
Martin Weinelt d86c1778e2 docs/migrations: fix typo in migration #4 2026-05-24 21:19:40 +02:00
Martin Weinelt f8f71c820a docs: warn about lack of trimming on the LDAP bind password 2026-05-24 21:03:47 +02:00
Martin Weinelt 82d9924cdf docs: update configuration examples 2026-05-24 21:03:47 +02:00
Martin Weinelt 3ad66c854d README: add 26.05 release info
Prune old and deprecated releases at the same time.

Also reword the section intro to mention version compat requirements.
2026-05-24 21:03:47 +02:00
Martin Weinelt 9764b64190 hydra: create nixos-26.05 jobset 2026-05-24 21:03:47 +02:00
Martin Weinelt a6bb0dde9b docs: rewrite 26.05 release notes 2026-05-24 21:03:47 +02:00
Martin Weinelt 2e3a2d0980 docs/setup-guide: fix grammer in SMTP port requirement 2026-05-24 14:30:18 +02:00
Martin Weinelt f8e3955323 docs/setup-example: enable nginx
Nginx requires explicit enablement or ACME won't work.
2026-05-24 14:27:21 +02:00
Martin Weinelt 7aeb1d7d76 docs: add missing dig +short flags in setup guide 2026-05-24 13:28:47 +02:00
Martin Weinelt e5ae7b5b96 Merge branch 'sieve-migration' into 'main'
sieve: move `cfg.sieveDirectory` into home directory of virtual users

See merge request simple-nixos-mailserver/nixos-mailserver!508
2026-05-24 03:07:38 +00:00
emilylange c60d98a13c sieve: add migration story for cfg.sieveDirectory removal
Co-authored-by: Martin Weinelt <hexa@darmstadt.ccc.de>
2026-05-24 05:02:23 +02:00
Martin Weinelt 58ff4da02f Merge branch 'restore-default-index-path' into 'main'
dovecot: restore default mail_index_path

Closes #359

See merge request simple-nixos-mailserver/nixos-mailserver!525
2026-05-24 02:12:13 +00:00
Martin Weinelt 4dcd114a2f dovecot: restore default mail_index_path
Back in 2.3 the index was by default kept in the maildir. This is also
the default in 2.4, but during the migration I put the dovecot home dir
as the default index path, which is a breaking change and could cause
client resyncs.

Fixes: #359
2026-05-24 04:00:39 +02:00
Martin Weinelt e4e18e01de Merge branch 'ldap-auth-bind' into 'main'
dovecot: fix non-default `cfg.ldap.attributes.password`, reintroduce LDAP bind auth for passdb

Closes #360

See merge request simple-nixos-mailserver/nixos-mailserver!524
2026-05-24 00:20:28 +00:00
emilylange eea473ea12 dovecot: reintroduce LDAP bind auth for passdb
LDAP bind auth used to be enabled by default (and not configurable)
before the dovecot 2.4 migration.

I changed the default option value to match the old Dovecot 2.3
behavior.

The use of authentication bind is required for LDAP servers that simply
do not have such LDAP attribute like Kanidm, or in cases where the
password scheme used is not supported by Dovecot.
2026-05-24 02:01:55 +02:00
emilylange 57bfae2d7e dovecot: fix non-default cfg.ldap.attributes.password
The option got recently introduced, but never properly wired.
2026-05-24 01:30:59 +02:00
Martin Weinelt e5102c5502 Merge branch 'opt-desc-down' into 'main'
docs: move option description below type, default and example

See merge request simple-nixos-mailserver/nixos-mailserver!522
2026-05-23 19:43:16 +00:00
Martin Weinelt 1d6f18856a Merge branch 'prek' into 'main'
pre-commit: migrate to prek

See merge request simple-nixos-mailserver/nixos-mailserver!521
2026-05-23 18:06:54 +00:00
Martin Weinelt 800bf95755 docs: move option description below type, default and example
This puts the important facts first and pulls the description way down
to the end of the option documentation.
2026-05-23 19:41:21 +02:00
Martin Weinelt c0cc5e7eff pre-commit: migrate to prek
Same functionality with smaller depdency closure.
2026-05-23 19:35:32 +02:00
Martin Weinelt 61e9c248c5 Merge branch 'flake-update' into 'main'
flake.lock: Update

See merge request simple-nixos-mailserver/nixos-mailserver!520
2026-05-21 15:57:25 +00:00
Martin Weinelt 10dce12f73 tests/ldap: check regex match return value
error[unresolved-attribute]: Attribute `group` is not defined on `None` in union `Match[str] | None`
   --> testScriptWithTypes:152:21
    |
152 |   ldap_table_path = re.match('.* =.*ldap:(.*)', conf).group(1)
    |                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    |
2026-05-21 17:28:36 +02:00
Martin Weinelt 86a2bb9afd flake.lock: Update
Flake lock file updates:

• Updated input 'git-hooks':
    'github:cachix/git-hooks.nix/580633fa3fe5fc0379905986543fd7495481913d' (2026-04-07)
  → 'github:cachix/git-hooks.nix/61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a' (2026-05-11)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/9a3a5b8400951b3497d2ef8f239f8451175cf3a1' (2026-04-18)
  → 'github:NixOS/nixpkgs/657e2fa0760e27167cdacb1ec5d84782be312013' (2026-05-21)
2026-05-21 17:20:31 +02:00
emilylange e4aa2d1517 sieve: move cfg.sieveDirectory into home directory of virtual users 2026-05-02 20:02:31 +02:00
emilylange 260f38128e sieve: offload mailserver.loginAccounts.<name>.sieveScript into /nix/store
This simplifies the remaining structure of `cfg.sieveDirectory`
a lot and gets us one step closer to removing
`activate-virtual-mail-users.service`.
2026-05-02 20:00:03 +02:00
emilylange 28bfef89ba tests: test mailserver.loginAccounts.<name>.sieveScript 2026-05-02 19:54:32 +02:00
Martin Weinelt e33fbde199 Merge branch 'push-pxvmorlwmyns' into 'main'
{rspamd,borgbackup}: use package from upstream NixOS service

See merge request simple-nixos-mailserver/nixos-mailserver!519
2026-04-27 10:58:13 +00:00
Michael Hoang fb38d437a5 borgbackup: use package from upstream NixOS service 2026-04-27 12:43:59 +02:00
Michael Hoang f810a804c6 rspamd: use package from upstream NixOS service 2026-04-27 12:23:04 +02:00
Martin Weinelt 583a362c5b Merge branch 'tls-updates' into 'main'
TLS updates

See merge request simple-nixos-mailserver/nixos-mailserver!518
2026-04-26 21:48:52 +00:00
Martin Weinelt 3ab15c2e30 docs/release-notes: add tls changes 2026-04-26 01:47:39 +02:00
Martin Weinelt ecbe707330 postfix/dovecot: support SecP256r1MLKME768 key exchange
Added support means we allow it, but for now we don't prefer it, since it
has not seen much use yet. For Postfix that means it lands below the two
groups that already send a key share and save us a roundtrip.

https://www.ietf.org/archive/id/draft-kwiatkowski-tls-ecdhe-mlkem-02.html
2026-04-26 01:04:33 +02:00
Martin Weinelt 7909eabac2 postfix: require AEAD & ECDHE cipher suites
This drops ARIA, Camellia and AES-CBC support from TLSv1.2 cipher suites.

When we explicitly restrict the cipherlist in Postfix, then we need to
define TLSv1.3 cipher suites in our OpenSSL config file.
2026-04-26 01:04:33 +02:00
Martin Weinelt 8d6b14c82c postfix: restrict TLS signing algorithms
Prunes the list preset and removes SHA-1 to restore compatibility with
NCSC TLS security guidelines.
2026-04-26 01:04:32 +02:00
Martin Weinelt e6c4a96f50 Merge branch 'fix/overeager-scheme-prepend' into 'main'
Only prepend {CRYPT} scheme if there is no scheme present

See merge request simple-nixos-mailserver/nixos-mailserver!517
2026-04-23 13:29:01 +00:00
Charlotte Van Petegem 6e9a4420b3 Only prepend {CRYPT} scheme if there is no scheme present 2026-04-23 14:45:22 +02:00
Martin Weinelt 0b1ca54241 hydra: use nixpkgs-unstable instead of nixos-unstable-small
We don't need the fast pace of unstable-small, but we still want to stay
current with built packages on unstable for evaluations.
2026-04-21 15:00:29 +02:00
Martin Weinelt bd5b08681a Merge branch 'dovecot-2.4.3' into 'main'
dovecot: migrate to dovecot 2.4

See merge request simple-nixos-mailserver/nixos-mailserver!512
2026-04-20 23:23:08 +00:00
Martin Weinelt 198246f2c2 fts: update docs and defaults 2026-04-21 00:58:58 +02:00
Martin Weinelt f9d1435378 dovecot: migrate to dovecot 2.4 2026-04-20 15:39:36 +02:00
Martin Weinelt 7dce7fbd5a Merge branch 'add-option-custom-reject-sender-message-release-notes' into 'main'
Add Release Note for rejectSenderMessage and fix typo

See merge request simple-nixos-mailserver/nixos-mailserver!515
2026-04-19 14:27:24 +00:00
Lennart Mühlenmeier 99a9b6efb7 Add Release Note for rejectSenderMessage and fix typo
Forgot about adding a Release Note for rejectSenderMessage
https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/453,
also fixing a typo in that already merged commit I just noticed.
2026-04-19 09:32:41 +02:00
Martin Weinelt fdb1be9b50 Merge branch 'update-dovecot-hostname' into 'main'
dovecot: fix hostname to fqdn

See merge request simple-nixos-mailserver/nixos-mailserver!510
2026-04-19 00:12:04 +00:00
Martin Weinelt 21399f334c Merge branch 'update-rspamd-headers' into 'main'
rspamd: add authentication-results header

See merge request simple-nixos-mailserver/nixos-mailserver!513
2026-04-19 00:02:44 +00:00
Martin Weinelt 7fe61cc1a3 Merge branch 'tests-uds-helper' into 'main'
tests: migrate to wait_for_open_unix_socket helper

See merge request simple-nixos-mailserver/nixos-mailserver!514
2026-04-18 21:12:40 +00:00
Martin Weinelt 25fae6f36e tests: migrate to wait_for_open_unix_socket helper 2026-04-18 23:04:09 +02:00
Lafiel 903d0cc8ad rspamd: add authentication-results header 2026-04-18 18:10:00 +03:00
Martin Weinelt e4017308b2 flake.lock: Update
Flake lock file updates:

• Updated input 'git-hooks':
    'github:cachix/git-hooks.nix/c06f90f1eb6569bdaf6a4a10cb7e66db4454ac2a' (2026-03-31)
  → 'github:cachix/git-hooks.nix/580633fa3fe5fc0379905986543fd7495481913d' (2026-04-07)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/c88e63f4caf12c731f61ce71f300680ce73c180e' (2026-04-12)
  → 'github:NixOS/nixpkgs/9a3a5b8400951b3497d2ef8f239f8451175cf3a1' (2026-04-18)
2026-04-18 16:22:38 +02:00
Martin Weinelt 93b4e5f3cd Merge branch 'quotaUsers' into 'main'
dovecot: fix quota users assertion

See merge request simple-nixos-mailserver/nixos-mailserver!511
2026-04-16 00:23:56 +00:00
isabel 10b577c650 dovecot: fix quota users assertion 2026-04-16 01:04:07 +01:00
Lafiel c67cc808ce dovecot: fix hostname to fqdn 2026-04-15 19:30:27 +03:00
Martin Weinelt ceb3f17fe1 Merge branch 'restore-dovecot-hierarchy-separator' into 'main'
dovecot: restore hierarchy separator setting

See merge request simple-nixos-mailserver/nixos-mailserver!509
2026-04-14 13:59:05 +00:00
Martin Weinelt bb1728f27c dovecot: restore hierarchy separator setting
The application of this setting got lost in the structured settings
migration.

Ref: 44149c5
2026-04-14 14:33:29 +02:00
Martin Weinelt 4ddd48b573 Merge branch 'dovecot-rfc42' into 'main'
dovecot: migrate to settings option

See merge request simple-nixos-mailserver/nixos-mailserver!498
2026-04-12 23:25:29 +00:00
Martin Weinelt f1e4af7184 dovecot: run lmtp service under storage owner user
Previously it ran as root, which is not required since we use a single
uid/gid for all mail storage.
2026-04-13 01:19:14 +02:00
Martin Weinelt 0da8e2b197 quota: expose global quota settings
With the options in the upstream dovecot module gone the quota support
and its option now live in our downstream module.

The only behavior change this introduces is not setting a global per
user default instead of the previous 100G per user.

Diabling quota support and setting per user quotas now raises an
assertion:

````
Failed assertions:
- Without quota support enabled, per-user quotas cannot be applied to the following accounts:

  - lowquota@example.com

  Either remove per user quota settings or re-enable `mailserver.quota.enable`.
````
2026-04-13 01:19:14 +02:00
Martin Weinelt 44149c527e dovecot: migrate to settings option 2026-04-13 01:19:14 +02:00
Martin Weinelt ffb64609a5 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/2f4fd5e1abf9bac8c1d22750c701a7a5e6b524c6' (2026-03-31)
  → 'github:NixOS/nixpkgs/c88e63f4caf12c731f61ce71f300680ce73c180e' (2026-04-12)
2026-04-13 01:19:14 +02:00
Martin Weinelt d98a6302f1 ci: run on main branch 2026-04-12 03:59:39 +02:00
Martin Weinelt 5688b25151 hydra: drop nixos-25.05 branch 2026-04-12 03:54:42 +02:00
Martin Weinelt 3277481550 hydra: migrate tests from master to main 2026-04-12 03:54:42 +02:00
Martin Weinelt 1b33655bcb Switch default branch to main 2026-04-12 03:54:39 +02:00
Martin Weinelt 44c63067d4 hydra: run declarative jobset against unstable-small
This is a moving target. Before we were sitting on a commit from 2020.
2026-04-12 01:58:08 +02:00
Martin Weinelt c45a1e4385 docs: bump stateVersion in setup-example
The setup example is for new users who don't need to do any migrations
just yet.
2026-04-03 21:25:24 +02:00
Martin Weinelt 493f0ff8a7 Merge branch 'ldap-uuid-attr-crash' into 'master'
dovecot: drop redundant uuid mapping in user_attrs

Closes #352

See merge request simple-nixos-mailserver/nixos-mailserver!506
2026-03-31 23:21:42 +00:00
Martin Weinelt 42650aad4d dovecot: drop redundant uuid mapping in user_attrs
This was redundant at best and crashing Dovecot at worst, due to multiple
requests for the uuid field name.

Closes: #352
2026-03-31 23:17:08 +02:00
Martin Weinelt f18985058e flake.lock: Update
Flake lock file updates:

• Updated input 'git-hooks':
    'github:cachix/git-hooks.nix/f799ae951fde0627157f40aec28dec27b22076d0' (2026-03-21)
  → 'github:cachix/git-hooks.nix/c06f90f1eb6569bdaf6a4a10cb7e66db4454ac2a' (2026-03-31)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/2cb1420c66c8e634314ce0abf70680208177f5b4' (2026-03-22)
  → 'github:NixOS/nixpkgs/2f4fd5e1abf9bac8c1d22750c701a7a5e6b524c6' (2026-03-31)
2026-03-31 16:18:10 +02:00
Martin Weinelt 0e176193a2 Fix various issues in the storage option descriptiosn
Especially a mistake where I confused rst and markdown syntax for
referencing options.
2026-03-25 18:32:54 +01:00
Martin Weinelt 07e82e06d8 Merge branch 'cleanup' into 'master'
Rename loginAccounts and group storage related settings

See merge request simple-nixos-mailserver/nixos-mailserver!501
2026-03-24 22:56:11 +00:00
Martin Weinelt 20f0e767cb users: remove unused common import 2026-03-24 01:58:37 +01:00
Martin Weinelt e13736db67 Group storage and vmail user options at mailserver.storage
Create a nicer option structure that deals with the mail storage and its
owner, uid, group and gid. Also includes the directory layout as a
property of how mails are stored..
2026-03-24 01:57:31 +01:00
Martin Weinelt 6826d11c58 users: remove global with config.mailserver 2026-03-24 01:35:48 +01:00
Martin Weinelt e9337b346f Rename mailserver.loginAccounts to mailserver.accounts
The "login" prefix makes this option more confusing rather than clearer,
because what other account types are there? LDAP ones for example, but
you can login with those too, so the prefix is pointless.
2026-03-24 01:35:48 +01:00
Martin Weinelt 5fdb686c66 docs: improve login account options 2026-03-24 01:35:48 +01:00
Martin Weinelt 3a1de3713c Merge branch 'ldap-storage-regression' into 'master'
dovecot: fix storage basedir regression in ldap home

See merge request simple-nixos-mailserver/nixos-mailserver!505
2026-03-24 00:35:35 +00:00
Martin Weinelt 854cb3ad3a tests: add regression test for custom ldap storage path
By setting a custom mail storage path the home dir lookups will fail and
signal something is wrong.
2026-03-24 01:29:27 +01:00
Martin Weinelt 4f3d21f386 dovecot: fix storage basedir regression in ldap home
During the rewrite of the LDAP userdb field lookups the default path for
the mail storage directory accidentally leaked into the home directory
path.
2026-03-24 01:11:09 +01:00
Martin Weinelt 2410c89f61 Merge branch 'ldap-local-coex' into 'master'
ldap: allow coexistence with local accounts

See merge request simple-nixos-mailserver/nixos-mailserver!502
2026-03-23 23:26:33 +00:00
Martin Weinelt ff5efdeeb6 Update forwards option description
Mixing examples and description in the description makes it very noisy
and unfocused.
2026-03-23 16:26:32 +01:00
Martin Weinelt 31c7607ef4 Rename extraVirtualAliases to aliases and update description
The extra and virtual parts are redundant and Postfix specific and not
at all required. Compare forwards for example.
2026-03-23 16:26:32 +01:00
Martin Weinelt 23364b04e8 ldap: allow local accounts and aliases with ldap enabled
In conflicts between local addresses and LDAP addresses the local one
will always take priority in mail routing.

This is something we now document and guarantee through tests.
2026-03-23 16:25:50 +01:00
Martin Weinelt 86d256870b postfix: prune virtual delivery agent settings
We exclusively rely on delivery via dovecot-lmtp, so these are redundant.
2026-03-23 13:23:01 +01:00
Martin Weinelt 14717e52a0 Merge branch 'flake-update' into 'master'
flake.lock: Update

See merge request simple-nixos-mailserver/nixos-mailserver!504
2026-03-23 01:40:03 +00:00
Martin Weinelt 2e6711bbdd docs: remove email from acme default configuration
This is not required any longer since
https://github.com/NixOS/nixpkgs/pull/489983
2026-03-23 02:31:14 +01:00
Martin Weinelt 569ed84e4b flake.lock: Update
Flake lock file updates:

• Updated input 'git-hooks':
    'github:cachix/git-hooks.nix/8baab586afc9c9b57645a734c820e4ac0a604af9' (2026-03-07)
  → 'github:cachix/git-hooks.nix/f799ae951fde0627157f40aec28dec27b22076d0' (2026-03-21)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/0c6c0dd2469abaa216599bb19bbf77a328af6564' (2026-03-09)
  → 'github:NixOS/nixpkgs/2cb1420c66c8e634314ce0abf70680208177f5b4' (2026-03-22)
2026-03-23 02:29:28 +01:00
Martin Weinelt 148c2f9beb Merge branch 'typos-hook' into 'master'
Check for obvious typos in pre-commit

See merge request simple-nixos-mailserver/nixos-mailserver!503
2026-03-23 00:43:46 +00:00
Martin Weinelt 4ef8541b11 treewide: fix typos 2026-03-23 01:35:59 +01:00
Martin Weinelt 625d607365 Check for obvious typos in pre-commit 2026-03-23 01:35:27 +01:00
Martin Weinelt 097219b2dd docs: fix download url for migration script 2026-03-23 00:52:08 +01:00
Martin Weinelt 5d715c4ce8 assertions: adjust docs url for migration #4 2026-03-22 15:03:18 +01:00
Martin Weinelt 4b6a7450e8 Merge branch 'ldap-updates' into 'master'
LDAP: UUID based homedirs, username based login, group attribute options, docs

Closes #323 and #342

See merge request simple-nixos-mailserver/nixos-mailserver!493
2026-03-22 13:57:37 +00:00
Martin Weinelt 98acd76bbf Add migration story for LDAP UUID home directories 2026-03-21 22:34:50 +01:00
Martin Weinelt 59eae7f3d0 tests/ldap: remove redundant settings
All of thsese are already option defaults.
2026-03-21 22:34:50 +01:00
Martin Weinelt a70ae543cb docs: add baseline ldap documentation
within the new account backends nav section.
2026-03-21 22:34:50 +01:00
Martin Weinelt 63365fb1a8 postfix: document ldap map purposes 2026-03-21 01:38:04 +01:00
Martin Weinelt 762f553643 ldap: make uid the default account name
I fail to understand how mail became the uidAttribute way back when LDAP
support was introduced, but it was unintentional and clearly a mistake.

The uid attribute is the standard system login name per RFC4519 2.39 and
what we default to going forward.
2026-03-21 01:38:04 +01:00
Martin Weinelt a87d01ea79 ldap: reorganize and regroup options
Now that we have more experience with how we use the LDAP module options
we can make smarter decisions in how to organize them. We can also
explain much better what these options imply, which results in more
extensive option documentation.
2026-03-21 01:38:04 +01:00
Martin Weinelt 609fd80936 dovecot: make sure vid/gid are not overridable
The only storage scheme we support is a single declarative user with
fixed uid/gid. The default_fields are overridable if these fields leak
in from LDAP, so promote them to override_fields instead.
2026-03-21 00:47:59 +01:00
Martin Weinelt af480dba87 ldap: replace pass_attrs option with password attr option
The passdb only checks password access, so instead of customizing the
whole pass_attrs setting we now allow customization of the password field
used.
2026-03-21 00:47:59 +01:00
Martin Weinelt 091eda1ed2 ldap: migrate to UUID based Dovecot home directories
The LDAP support was not in a good shape when it was merged. This is a
breaking change and course correction to apply best practices going
forward.

This fixes various issues experienced with the Dovecot LDAP home
directory.

The gravest issue is that the `homeDirectory` attribute from
the `posixAccount` schema would overwrite the Dovecot home directory and
cause permission errors. This was possible because we defined the home
variable in `default_fields` that is inherently mutable and just a preset
if no other value gets transmitted from LDAP. This did not surface in
tests, because our LDAP schema was too minimal compared to a common
production dataset.

The most annoying issue and the actual breaking change is that we now
default to UUID based home directories. Every entry in an IDM that
supports LDAP comes with a unique identifier that does not change upon
account name changes. We want those to enable simple account name
migrations that don't require any manual data migration.

To migrate existing dovecot home directories a migration script is
included, which will be backported to the 25.11 release, so the migration
can already be started from the previous release version.
2026-03-21 00:47:59 +01:00
Martin Weinelt fa0d5c9694 tests/ldap: fail fast if openldap schema is broken
This helps so much during development as it tells me openldap failed and
doesn't require me to do a root cause analysis on a postmap failure much
later in during the test.
2026-03-21 00:47:59 +01:00
Martin Weinelt 05968d7978 Merge branch 'add-option-custom-reject-sender-message' into 'master'
Add rejectSenderMessage option

See merge request simple-nixos-mailserver/nixos-mailserver!453
2026-03-20 10:16:09 +00:00
lennart 5544b0fa70 Add rejectSenderMessage option 2026-03-20 10:16:09 +00:00
Martin Weinelt fb3350c188 Merge branch 'roundcube-doc-plugin-maxsize-fix' into 'master'
docs/roundcube: fix mistakes in the example and add examples for caddy and managesieve

See merge request simple-nixos-mailserver/nixos-mailserver!499
2026-03-19 20:32:02 +00:00
headpats 3dc19d30d1 docs/roundcube: add caddy example 2026-03-19 13:36:00 +01:00
headpats cbf450f06c docs/roundcube: fix typo in nginx vhost 2026-03-19 13:36:00 +01:00
headpats bf481fd2e5 docs/roundcube: add managesieve example 2026-03-19 13:36:00 +01:00
headpats 8d5aa0b27a docs/roundcube: attachment size workaround is now handled by the module 2026-03-19 13:35:52 +01:00
Martin Weinelt b442cb49ee Merge branch 'dovecot-rsa-compat' into 'master'
dovecot: restore compat for RSA key material

See merge request simple-nixos-mailserver/nixos-mailserver!500
2026-03-19 08:55:14 +00:00
Martin Weinelt 3da442701a dovecot: restore compat for RSA key material
In TLS1.2 available cipher suites depend on the available key material,
so the last round of cipher suites updates broke TLS1.2 support with RSA
key pairs.

The NixOS ACME module has been defaulting to EC256 (and earlier EC384)
key material, so I assume this did not affect many setups in practice.
2026-03-18 14:06:10 +01:00
headpats 83a669fb2f docs/roundcube: fix persistent_login plugin not being activated 2026-03-18 05:58:41 +01:00
Martin Weinelt 7dfcb21d35 scripts/generate-options: fix typing issue 2026-03-15 19:24:49 +01:00
Martin Weinelt 75f9549a81 Merge branch 'pr/misc-fix' into 'master'
a few fixes

See merge request simple-nixos-mailserver/nixos-mailserver!497
2026-03-15 00:36:10 +00:00
Lin Jian 6606537c0f docs/setup-guide: fix name of DKIM record
ref: 4089d73b51
ref: !488
2026-03-15 05:49:10 +08:00
Lin Jian 4a8f0c9da6 Fix option typo
ref: 6ff4a50f02
ref: !484
2026-03-15 05:49:10 +08:00
Lin Jian e31adfca1a Add missing mkRenamedOptionModule for dkimKeyBits
ref: 6ff4a50f02
ref: !484
2026-03-15 05:49:10 +08:00
Martin Weinelt 58587e09bd Merge branch 'deprecations' into 'master'
Deprecate borgbackup, rsnapshot, monit integrations

See merge request simple-nixos-mailserver/nixos-mailserver!494
2026-03-14 11:18:04 +00:00
Martin Weinelt 33b8946c87 Deprecate borgbackup, rsnapshot, monit integrations
The plan is to start warning now with the intent to remove these
integrations before the 26.11 release.
2026-03-14 04:30:39 +01:00
Martin Weinelt 86579c6715 Merge branch 'qol-changes' into 'master'
treewide: inline language instructions, reorganize imports

See merge request simple-nixos-mailserver/nixos-mailserver!492
2026-03-12 12:51:08 +00:00
Martin Weinelt fdcb28e97e git-blame-ignore-revs: init
Adds a mechanism to track and subsequently ignore non-functional treewide
changes during git blame.
2026-03-12 13:43:09 +01:00
Martin Weinelt 271e6e54fd Reorganize imports
With the growing number of option refactors centralize the module import
within the mail-server directory.

Also group deprecations by release, so we can eventually yank them in
bulk after a while.
2026-03-12 13:21:51 +01:00
Martin Weinelt 06cc71c76e treewide: add language annotations for inline code
Instruct editors to correctly highlight and evaluate inline code blocks.
2026-03-12 12:59:03 +01:00
Martin Weinelt 405f2180d4 Merge branch 'docs-version' into 'master'
docs: set version to fix epub3 build

See merge request simple-nixos-mailserver/nixos-mailserver!491
2026-03-11 23:56:17 +00:00
Martin Weinelt 73d3ff008d docs: set version to fix epub3 build
>  WARNING: conf value "version" should not be empty for EPUB3
2026-03-12 00:52:00 +01:00
Martin Weinelt ed13d8e253 Merge branch 'dkim-key-management' into 'master'
Add support for DKIM key management

Closes #341

See merge request simple-nixos-mailserver/nixos-mailserver!484
2026-03-11 23:42:22 +00:00
Martin Weinelt 6ff4a50f02 Add support for DKIM key management
After bumping the generation of new DKIM keys to RSA 2048 in NixOS 25.11
key rotation for existing users could not be done safely.

To resolve this situation we now support multiple generations of
selectors per domain to enable proper DKIM key transitions as described
in RFC6376 3.1. The added documentation introduces and motivates DKIM
and guides the user through a DKIM key rotation.

Additionally, DKIM key material can now also be treated as a managed
secrets when autogenerated state on the mail server host is undesirable.

This change is fully backwards compatible in behavior and will continue
to use the previously generated DKIM key without any additional
configuration up until the point when DKIM selectors are configured
explicitly.
2026-03-11 22:37:49 +01:00
Martin Weinelt ea775773d9 docs: fail build on warnings 2026-03-11 17:01:39 +01:00
Martin Weinelt 489fbc4e0e Merge branch 'setup-guide-next' into 'master'
docs/setup-{guide,example}: refresh the whole guide

See merge request simple-nixos-mailserver/nixos-mailserver!488
2026-03-11 02:04:26 +00:00
Martin Weinelt 4089d73b51 docs/setup-{guide,example}: refresh the whole guide
- add many motivation, helpful comments and important details
- improve formatting through use of more native sphinx/rst elements, like
  the csv-table for DNS records
- clarify the basic requirements
- use dig for uncached DNS propagation checks against an authoritative
  nameserver
- explain the basic feature set of the setup example
- adjust DNS TTLs; 1h is a common duration in modern setups and does not
  hurt caching much
- remove mention of the announce mailinglist, users can just expect
  releases to be ready around branch-off
2026-03-11 02:58:55 +01:00
Martin Weinelt 88889601b9 Merge branch 'docs-update' into 'master'
docs: update how-to guides, split off integrations

See merge request simple-nixos-mailserver/nixos-mailserver!490
2026-03-11 00:54:08 +00:00
Martin Weinelt 1c57aab586 treewide: fix typos and other minor issues 2026-03-11 01:50:14 +01:00
Martin Weinelt d04d1a565b docs: reorganize how-to section, create integrations section
Radicale and Roundcube don't fit so well with the other how to's in
that they configure additional external services instead of directly
modifying the NixOS mailserver setup.

We also sort the How-To section alphabetically. his unclutters the nav
somewhat
2026-03-11 01:50:14 +01:00
Martin Weinelt 5e43dafc96 docs: update Flakes example
and add a small example how to deploy with nixos-rebuild.
2026-03-11 01:50:13 +01:00
Martin Weinelt b83621011f docs: update autodiscovery guide 2026-03-11 01:50:13 +01:00
Martin Weinelt 8d996b109d docs: update Roundcube guide
Adds a short explanation what roundcube even is.

Extract and extend the roundcube example showing plugin and spellchecking
support. We also inherit a plausible maximum attachment size based on
Postfix's message_size_limit. The nginx vhost forces TLS and manages
certificates using the ACME integration.
2026-03-11 01:50:13 +01:00
Martin Weinelt cff7a27cfe docs: update Radicale guide
We now explain what Radicale even is and classify reusing the hashed
passwords of login accounts as limitation because it requires using
compatible password hashes.

This is difficult because compatible password hashes need an overlap
between libxcrypt and Radicales choice of libraries: libpass, argon2 and
bcrypt.

Extract the source code into a proper .nix file so we get source linting
and formatting for free. Pruned from bad practices of the past, like
global `with lib`.
2026-03-10 02:02:53 +01:00
Martin Weinelt 1240173034 Merge branch 'fix-rspamd-doc' into 'master'
docs: fix rspamd syntax

See merge request simple-nixos-mailserver/nixos-mailserver!310
2026-03-09 21:00:17 +00:00
Martin Weinelt 77205f744e Merge branch 'flake-update' into 'master'
flake.lock: Update

See merge request simple-nixos-mailserver/nixos-mailserver!489
2026-03-09 12:36:54 +00:00
Martin Weinelt 3758b622f2 flake.lock: Update
Flake lock file updates:

• Updated input 'git-hooks':
    'github:cachix/git-hooks.nix/39f53203a8458c330f61cc0759fe243f0ac0d198' (2026-03-04)
  → 'github:cachix/git-hooks.nix/8baab586afc9c9b57645a734c820e4ac0a604af9' (2026-03-07)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/d2acf504d602c98f5ec2518dacea4f35e5a4e50f' (2026-03-05)
  → 'github:NixOS/nixpkgs/0c6c0dd2469abaa216599bb19bbf77a328af6564' (2026-03-09)
2026-03-09 13:27:02 +01:00
Martin Weinelt c292d31ee7 Merge branch 'dkim-dns-binding-no-service' into 'master'
docs: remove service type key from DKIM DNS binding

See merge request simple-nixos-mailserver/nixos-mailserver!487
2026-03-08 21:56:45 +00:00
Martin Weinelt 6cee3e2360 docs: remove service type key from DKIM DNS binding
Stop explicitly restriciting us to email services. This would require
an update for tlsrpt (s=email:tlsrpt) use but the benefit of restricting
key use like that has limited practical benefit, when there are so very\
few services defined.

Not setting the service type key defaults it to all services (s=*).
2026-03-08 22:53:11 +01:00
Martin Weinelt 80ce71e236 docs/advanced-cofnigurations: expand recommendations
Mention FTS and TLSRPT and explain what these setups are good for and
when they might be required.
2026-03-08 04:36:58 +01:00
Martin Weinelt e193287dc1 Fix inline code block in mailserver.forwards option description
It should surround the whole attribute set, not leave out the opening
bracket.

Closes: #345
2026-03-08 03:03:41 +01:00
Martin Weinelt c04152fa90 Merge branch 'flake-update' into 'master'
flake.lock: Update

See merge request simple-nixos-mailserver/nixos-mailserver!485
2026-03-06 02:11:56 +00:00
Martin Weinelt b600abd389 flake.lock: Update
Flake lock file updates:

• Updated input 'git-hooks':
    'github:cachix/git-hooks.nix/a8ca480175326551d6c4121498316261cbb5b260' (2026-02-01)
  → 'github:cachix/git-hooks.nix/39f53203a8458c330f61cc0759fe243f0ac0d198' (2026-03-04)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/fff0554c67696d76a0cdd9cfe14403fbdbf1f378' (2026-02-09)
  → 'github:NixOS/nixpkgs/d2acf504d602c98f5ec2518dacea4f35e5a4e50f' (2026-03-05)
2026-03-06 03:05:29 +01:00
Martin Weinelt 3938a7518a docs: fix typo and wording in release notes 2026-03-05 15:51:57 +01:00
Martin Weinelt e458653769 Merge branch 'docs-update' into 'master'
Update release notes

See merge request simple-nixos-mailserver/nixos-mailserver!483
2026-03-05 12:22:17 +00:00
Martin Weinelt 85967440af docs: configure ACME HTTP-01 with nginx in setup example 2026-03-05 12:52:04 +01:00
Martin Weinelt c300fdeb63 docs: mention password file option in release notes 2026-03-05 12:51:45 +01:00
Martin Weinelt 9b5e4d9753 Merge branch 'unhashed-password' into 'master'
Add support for plaintext password files

See merge request simple-nixos-mailserver/nixos-mailserver!474
2026-03-05 11:16:40 +00:00
Ryan Gibb 12ae5dd89b support unhashed password files 2026-03-05 11:06:01 +00:00
Martin Weinelt e1afec5b08 tests: wait for rspamd-milter.sock in ldap and internal tests
I've hit more races in these tests recently while running the test suite
on a much faster host system.
2026-03-04 16:02:47 +01:00
Martin Weinelt ff91d3cf68 pre-commit: fix nixfmt-rfc-style name deprecation
> warning: nixfmt-rfc-style is now the same as pkgs.nixfmt which should
> be used instead.
2026-03-04 16:01:52 +01:00
Martin Weinelt 25eae48a09 tests: fix eicar test string escape
This fixes a warning issued by the Lix evaluator:

> warning: \P is an ill-defined escape. You can drop the \ and simply
> write P instead. Use --extra-deprecated-features broken-string-escape
> to silence this warning.
2026-03-04 15:53:30 +01:00
Martin Weinelt ea4dc17f4b Merge branch 'setup-guide-spf-mx' into 'master'
docs: suggest mx to refer to mailserver in spf record

See merge request simple-nixos-mailserver/nixos-mailserver!481
2026-02-26 00:13:36 +00:00
Martin Weinelt bd03afc003 Merge branch 'rspamd-duplicate-systemd' into 'master'
postfix: fix duplicate systemd dependencies on rspamd

See merge request simple-nixos-mailserver/nixos-mailserver!479
2026-02-26 00:10:48 +00:00
Martin Weinelt 034ca15318 docs: suggest mx to refer to mailserver in spf record
Much more foolproof in simple setups, because it allows all servers
mentioned in a domains MX record to also send out mail, without having to
track them here manually again.
2026-02-26 01:03:53 +01:00
Martin Weinelt 781e833633 Merge branch 'flake-update' into 'master'
flake.lock: Update

See merge request simple-nixos-mailserver/nixos-mailserver!480
2026-02-09 17:51:47 +00:00
Martin Weinelt 9a104e245d flake.lock: Update
Flake lock file updates:

• Updated input 'git-hooks':
    'github:cachix/git-hooks.nix/50b9238891e388c9fdc6a5c49e49c42533a1b5ce' (2025-11-24)
  → 'github:cachix/git-hooks.nix/a8ca480175326551d6c4121498316261cbb5b260' (2026-02-01)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/6a49303095abc094ee77dc243a9e351b642e8e75' (2025-11-28)
  → 'github:NixOS/nixpkgs/fff0554c67696d76a0cdd9cfe14403fbdbf1f378' (2026-02-09)
2026-02-09 18:39:47 +01:00
Martin Weinelt 4345460d30 flake.nix: Update flake-compat repo 2026-01-29 19:14:06 +01:00
teutat3s 9b90a9837a rspamd: fix duplicate systemd dependencies
These are also declared in mail-server/systemd.nix.
2025-12-28 20:40:33 +01:00
Martin Weinelt 7d433bf898 Merge branch 'dovecot-hybrid-curve' into 'master'
dovecot: update TLS requirements

See merge request simple-nixos-mailserver/nixos-mailserver!477
2025-12-21 12:54:46 +00:00
Martin Weinelt 3579eb0001 dovecot: restrict TLS cipher suites 2025-12-19 04:00:47 +01:00
Martin Weinelt 1415623586 dovecot: support X25519MLKEM768 hybrid kex 2025-12-19 03:13:47 +01:00
Martin Weinelt 616a57af55 Merge branch 'certmgmt-next' into 'master'
Switch to NixOS ACME module for certificate management

Closes #256 and #267

See merge request simple-nixos-mailserver/nixos-mailserver!457
2025-12-19 01:58:52 +00:00
Martin Weinelt e437760341 treewide: replace/remove dovecot2 service name
The unit name is now dovecot.service.
2025-12-19 02:52:55 +01:00
Martin Weinelt 4bbe0d7bab Fix option reference in aliasesRegExp option 2025-12-19 02:36:28 +01:00
Martin Weinelt ff9b046f0f Stop recommending bcrypt everywhere
By passing no method to mkpasswd we make it select the strongest cipher
that libxcrypt recommends.

Replaces the example hashes with yescrypt hashes, which is the current
default.
2025-12-19 02:36:28 +01:00
Martin Weinelt 33ba1ff52b Switch to NixOS ACME module for certificate management
Drop most of the existing certificate handling, because we're effectively
duplicating functionality that NixOS offers for free with better
design, testing and maintainance than what we could provide downstream.

The remaining two options are to reference an
existing `security.acme.certs` configuration through
`mailserver.x509.useACMEHost` or to provide existing key material via
`mailserver.x509.certificateFile` and `mailserver.x509.privateKeyFile`.

Support for automatic creation of self-signed certificates has been
removed, because it is undesirable in public mail setups.

The updated setup guide now displays the recommended configuration that
relies on the NixOS ACME module, but requires further customization to
select a suitable challenge.

Co-Authored-By: Emily <git@emilylange.de>
2025-12-19 02:36:28 +01:00
Martin Weinelt 18ee2a44ed docs: extract setup example into .nix file and include
That way we get linting of the code for free.
2025-12-19 02:17:32 +01:00
Martin Weinelt e2a99f33ea docs: allow referencing module options 2025-12-15 16:02:24 +01:00
Martin Weinelt 1ccd57f177 Merge branch 'dkim-ed25519-warn' into 'master'
Warn about ED25519 DKIM usage

See merge request simple-nixos-mailserver/nixos-mailserver!473
2025-12-03 12:02:16 +00:00
Martin Weinelt 0d27ef2912 Merge branch 'master' into 'master'
docs: fix some typos in migrations guide

See merge request simple-nixos-mailserver/nixos-mailserver!472
2025-12-01 22:17:23 +00:00
Martin Weinelt 7d359e3ff5 Warn about ED25519 DKIM usage
There currently seems to be mixed support out there and we need to
support dual-signing first before we can recommend rolling out ED25519
DKIM keys.
2025-12-01 23:16:02 +01:00
yeoldegrove f67ed85b3f docs: fix some typos 2025-12-01 22:16:18 +01:00
Martin Weinelt 76bd7a85e7 Merge branch 'flake-update' into 'master'
flake.lock: Update

See merge request simple-nixos-mailserver/nixos-mailserver!471
2025-11-29 01:50:08 +00:00
Martin Weinelt e04e5b7ea6 assertions: bump mailserver version for release check 2025-11-29 02:43:16 +01:00
Martin Weinelt b8bffc8317 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/798ce8bfd0567bbd12ee633a88e53737969ec7d9' (2025-11-25)
  → 'github:NixOS/nixpkgs/6a49303095abc094ee77dc243a9e351b642e8e75' (2025-11-28)
2025-11-29 02:42:26 +01:00
Martin Weinelt 1d1a590e91 Merge branch 'docs-roundcube' into 'master'
docs: update roundcube example to use implicit TLS

Closes #336

See merge request simple-nixos-mailserver/nixos-mailserver!470
2025-11-29 01:31:35 +00:00
emilylange b47decd71a docs: update roundcube example to use implicit TLS
instead of explicit TLS (STARTTLS).

We disabled STARTTLS for IMAP by default in 54f37811dd
and we will likely do the same for (client) SMTP in the future.
2025-11-28 21:53:41 +01:00
Martin Weinelt 0696fcbe9b migrations: strongly indicate dry runs 2025-11-26 20:21:56 +01:00
Martin Weinelt a38e14460f docs: don't recommend sudo to run the migration script
The migration script tries switching EUID by itself and will error out
with a recommendation to try sudo if it cannot.
2025-11-26 20:18:58 +01:00
Martin Weinelt 039389ee04 docs: recommend wcurl to grab the migration script 2025-11-26 19:57:31 +01:00
Martin Weinelt 9c22ac0154 Merge branch 'flake-update' into 'master'
flake.lock: Update

See merge request simple-nixos-mailserver/nixos-mailserver!469
2025-11-25 13:19:18 +00:00
Martin Weinelt 760c23fb25 flake.lock: Update
Flake lock file updates:

• Updated input 'git-hooks':
    'github:cachix/git-hooks.nix/7275fa67fbbb75891c16d9dee7d88e58aea2d761' (2025-11-16)
  → 'github:cachix/git-hooks.nix/50b9238891e388c9fdc6a5c49e49c42533a1b5ce' (2025-11-24)
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/094318ea16502a7a81ce90dd3638697020f030a2' (2025-11-19)
  → 'github:NixOS/nixpkgs/798ce8bfd0567bbd12ee633a88e53737969ec7d9' (2025-11-25)
2025-11-25 14:05:20 +01:00
Naïm Favier 8aaa71f86e docs: fix rspamd syntax
See https://rspamd.com/doc/configuration/metrics.html
2023-12-11 15:34:03 +01:00
64 changed files with 4221 additions and 1968 deletions
+12
View File
@@ -0,0 +1,12 @@
# Ignore non-functional treewide changes by configuring
#
# $ git config blame.ignoreRevsFile .git-blame-ignore-revs
#
# or used temporarily with --ignore-revs-file=
#
# nixfmt
1a7f3d718c5a6406b7d5b54f10f5c9c69ed90ef9
# language hints
06cc71c76eb52dc747704a317ac5e175ebdd2ba8
+3 -3
View File
@@ -10,9 +10,9 @@ hydra-pr:
variables:
jobset: $CI_MERGE_REQUEST_IID
hydra-master:
hydra-main:
extends: .hydra-cli
only:
- master
- main
variables:
jobset: master
jobset: main
+2 -2
View File
@@ -30,8 +30,8 @@ let
};
desc = prJobsets // {
"master" = mkFlakeJobset "master";
"nixos-25.05" = mkFlakeJobset "nixos-25.05";
"main" = mkFlakeJobset "main";
"nixos-26.05" = mkFlakeJobset "nixos-26.05";
"nixos-25.11" = mkFlakeJobset "nixos-25.11";
};
+2 -2
View File
@@ -12,12 +12,12 @@
"type": 0,
"inputs": {
"nixexpr": {
"value": "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver master",
"value": "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver main",
"type": "git",
"emailresponsible": false
},
"nixpkgs": {
"value": "https://github.com/NixOS/nixpkgs 0f920b05cbcdb8c0f3c5c4a8ea29f1f0065c7033 ",
"value": "https://github.com/NixOS/nixpkgs nixpkgs-unstable",
"type": "git",
"emailresponsible": false
},
+1
View File
@@ -20,6 +20,7 @@ build:
sphinx:
configuration: docs/conf.py
fail_on_warning: true
formats:
- pdf
+2
View File
@@ -0,0 +1,2 @@
[rstcheck]
ignore_messages = Hyperlink target ".*" is not referenced.
+2
View File
@@ -0,0 +1,2 @@
[default.extend-identifiers]
reportd = "reportd"
+12 -19
View File
@@ -1,28 +1,24 @@
# ![Simple Nixos MailServer][logo]
![license](https://img.shields.io/badge/license-GPL3-brightgreen.svg)
[![pipeline status](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/badges/master/pipeline.svg)](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/master)
[![pipeline status](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/badges/main/pipeline.svg)](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/main)
## Release branches
For each NixOS release, we publish a branch. You then have to use the
SNM branch corresponding to your NixOS version.
We publish a branch for each NixOS release. Only matching branch versions are
supported.
* 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 26.05
* Use the [`nixos-26.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.11) branch
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/release-notes.html#nixos-26-05)
* For NixOS unstable
* Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master)
* Use the [`main`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/main) branch
* [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/)
## Features
* [x] Continous Integration Testing
* [x] Continuous Integration Testing
* [x] Multiple Domains
* Postfix
* [x] SMTP on port 25
@@ -46,6 +42,8 @@ SNM branch corresponding to your NixOS version.
* [x] Via ClamAV
* DKIM Signing
* [x] Via Rspamd
* [x] Automatic key generation
* [x] Multiple selectors per Domain
* User Management
* [x] Declarative user management
* [x] Declarative password management
@@ -66,13 +64,8 @@ SNM branch corresponding to your NixOS version.
* [ ] [Autoconfig](https://web.archive.org/web/20210624004729/https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration)
* [ ] [Autodiscovery](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019)
* [ ] [Mobileconfig](https://support.apple.com/guide/profile-manager/distribute-profiles-manually-pmdbd71ebc9/mac)
* DKIM Signing
* [ ] Allow per domain selectors
* [ ] 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)
* User management
* [ ] Allow local and LDAP user to coexist
* OpenID Connect
* Depends on relevant clients adding support, e.g. [Thunderbird](https://bugzilla.mozilla.org/show_bug.cgi?id=1602166)
@@ -93,7 +86,7 @@ See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/h
## Contributors
See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/master)
See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/main)
### Alternative Implementations
+649 -372
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXOPTS ?= --fail-on-warning
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
-55
View File
@@ -1,55 +0,0 @@
Add Radicale
============
Configuration by @dotlambda
Starting with Radicale 3 (first introduced in NixOS 20.09) the traditional
crypt passwords are no longer supported. Instead bcrypt passwords
have to be used. These can still be generated using `mkpasswd -m bcrypt`.
.. code:: nix
{ config, pkgs, lib, ... }:
with lib;
let
mailAccounts = config.mailserver.loginAccounts;
htpasswd = pkgs.writeText "radicale.users" (concatStrings
(flip mapAttrsToList mailAccounts (mail: user:
mail + ":" + user.hashedPassword + "\n"
))
);
in {
services.radicale = {
enable = true;
settings = {
auth = {
type = "htpasswd";
htpasswd_filename = "${htpasswd}";
htpasswd_encryption = "bcrypt";
};
};
};
services.nginx = {
enable = true;
virtualHosts = {
"cal.example.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:5232/";
extraConfig = ''
proxy_set_header X-Script-Name /;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_header Authorization;
'';
};
};
};
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
}
-32
View File
@@ -1,32 +0,0 @@
Add Roundcube, a webmail
========================
The NixOS module for roundcube nearly works out of the box with SNM. By
default, it sets up a nginx virtual host to serve the webmail, other web
servers may require more work.
.. code:: nix
{ config, pkgs, lib, ... }:
with lib;
{
services.roundcube = {
enable = true;
# this is the url of the vhost, not necessarily the same as the fqdn of
# the mailserver
hostName = "webmail.example.com";
extraConfig = ''
# starttls needed for authentication, so the fqdn required to match
# the certificate
$config['smtp_host'] = "tls://${config.mailserver.fqdn}";
$config['smtp_user'] = "%u";
$config['smtp_pass'] = "%p";
'';
};
services.nginx.enable = true;
networking.firewall.allowedTCPPorts = [ 80 443 ];
}
+12 -3
View File
@@ -9,6 +9,15 @@ 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.
- Configure regular, automatic `backups <backup-guide.html>`_.
- Enable `fulltext search <fts.html>`_ to let clients that dont sync all
mail efficiently find messages, by performing searches directly on the server.
- Set up the `Sender Rewriting Scheme`_ if you rely on server-side mail
forwarding to external mail servers using mail forwards or Sieve rules.
- Contribute `DMARC reports`_ and `SMTP TLS reports`_ to help improve email
security across the internet by sending feedback on authentication failures,
spoofing attempts, and TLS encryption issues.
.. _DMARC reports: options.html#mailserver-dmarcreporting
.. _SMTP TLS reports: options.html#mailserver-tlsrpt
.. _Sender Rewriting Scheme: srs.html
+67 -12
View File
@@ -1,18 +1,73 @@
Autodiscovery
=============
`RFC6186 <https://www.rfc-editor.org/rfc/rfc6186>`_ allows supporting email clients to automatically discover SMTP / IMAP addresses
of the mailserver. For that, the following records are required:
`RFC6186`_ defines how email clients can automatically discover a mail server's
SMTP and IMAP endpoints. To enable this, the following DNS records must be
configured:
================= ==== ==== ======== ====== ==== =================
Record TTL Type Priority Weight Port Value
================= ==== ==== ======== ====== ==== =================
_submission._tcp 3600 SRV 5 0 587 mail.example.com.
_submissions._tcp 3600 SRV 5 0 465 mail.example.com.
_imap._tcp 3600 SRV 5 0 143 mail.example.com.
_imaps._tcp 3600 SRV 5 0 993 mail.example.com.
================= ==== ==== ======== ====== ==== =================
.. csv-table:: Resource record set
:header: "Name", "TTL", "Type", "Priority", "Weight", "Port", "Value"
:widths: 30, 5, 5, 5, 5, 5, 20
Please note that only a few MUAs currently implement this. For vendor-specific
discovery mechanisms `automx <https://github.com/rseichter/automx2>`_ can be used instead.
_submissions._tcp.example.com., 3600, SRV, 10, 1, 465, mail.example.com.
_imaps._tcp.example.com., 3600, SRV, 10, 1, 993, mail.example.com.
Legacy records
^^^^^^^^^^^^^^
The following DNS records are only supported with
:option:`mailserver.enableSubmission` and :option:`mailserver.enableImap`,
because they only support connections with explicit TLS. These services are
disabled by default because they are deprecated through `RFC8314 4.1`_.
.. csv-table:: Resource record set
:header: "Name", "TTL", "Type", "Priority", "Weight", "Port", "Value"
:widths: 30, 5, 5, 5, 5, 5, 20
_submission._tcp.example.com., 3600, SRV, 20, 1, 587, mail.example.com.
_imap._tcp.example.com., 3600, SRV, 20, 1, 143, mail.example.com.
Client support
^^^^^^^^^^^^^^
*As researched in March 2026*
Only a small number of MUAs currently implement this. The most common concern
from the bigger and security-conscious vendors is lack of widespread DNSSEC
propagation that could be used to authenticate these SRV records.
- Aerc: since 0.20.1
- ``_submissions._tcp`` support submitted in https://lists.sr.ht/~rjarry/aerc-devel/patches/68173
- Evolution: Since 3.49.3 for mail accounts
- https://gitlab.gnome.org/GNOME/evolution/-/wikis/Autoconfig
- https://gitlab.gnome.org/GNOME/evolution/-/issues/941
Unsupported
***********
- DeltaChat:
- https://github.com/chatmail/core/issues/1508
- Thunderbird:
- Desktop: https://bugzilla.mozilla.org/show_bug.cgi?id=342242
- Android: https://github.com/thunderbird/thunderbird-android/issues/4721
Vendor-specific autoconfig
^^^^^^^^^^^^^^^^^^^^^^^^^^
The `automx2`_ service can provide autoconfig support for Apple's
`mobileconfig`_, Microsoft's `Autodiscover`_ and Mozilla's `Autoconfig`_
standards. It does however lack support for multiple mail domains and isn't open for
contributions due to copyright concerns.
.. _mobileconfig: https://support.apple.com/de-de/guide/profile-manager/pmdbd71ebc9/mac
.. _Autodiscover: https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019
.. _Autoconfig: https://benbucksch.github.io/autoconfig-spec/draft-ietf-mailmaint-autoconfig.html
.. _automx2: https://github.com/rseichter/automx2
.. _RFC6186: https://www.rfc-editor.org/rfc/rfc6186
.. _RFC8314 4.1: https://www.rfc-editor.org/rfc/rfc8314#section-4.1
+11 -17
View File
@@ -5,23 +5,17 @@ First off you should have a backup of your ``configuration.nix`` file
where you have the server config (but that is already in a git
repository right?)
Next you need to backup ``/var/vmail`` or whatever you have specified
for the option ``mailDirectory``. This is where all the mails reside.
Good options are a cron job with ``rsync`` or ``scp``. But really
anything works, as it is simply a folder with plenty of files in it. If
your backup solution does not preserve the owner of the files dont
forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them
back (or whatever you specified as ``vmailUserName``, and
``vmailGoupName``).
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.
Next you need to backup ``/var/vmail`` or whatever you have specified for the
option :option:`mailserver.storage.path`. This is where all the mails reside.
Good options are a cron job with ``rsync`` or ``scp``. But really anything
works, as it is simply a folder with plenty of files in it. If your backup
solution does not preserve the owner of the files dont forget to ``chown`` them
to ``virtualMail:virtualMail`` if you copy them back (or whatever you specified
as :option:`mailserver.storage.owner`, and :option:`mailserver.storage.group`).
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
step ``B)5`` and correct all the ``dkim`` keys.
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever you
specified as :option:`mailserver.dkim.keyDirectory`). If you should lose those
dont worry, new ones will be created on the fly. But you will need to update
the DKIM TXT records to reflect the new key material.
+1 -1
View File
@@ -20,7 +20,7 @@
project = "NixOS Mailserver"
copyright = "2022, NixOS Mailserver Contributors"
author = "NixOS Mailserver Contributors"
version = "26.05"
# -- General configuration ---------------------------------------------------
+205
View File
@@ -0,0 +1,205 @@
.. _dkim:
DKIM Signing
============
DKIM (DomainKeys Identified Mail) is an email authentication mechanism that
allows a mailserver to digitally sign outgoing emails for a domain. Receiving
mail servers can verify this signature using a public key published in DNS to
confirm the message was authorized by the domain and was not modified during
transit.
How DKIM works in practice
~~~~~~~~~~~~~~~~~~~~~~~~~~
1. ``bob@bar.example`` sends an email to ``alice@foo.example``. The sending
mail server for ``bar.example`` selects one or multiple DKIM keys using a
**selector** (e.g., ``mail``) and creates one or multiple cryptographic
signature for selected headers and the message body, adding a ``DKIM-Signature``
header that references the selector.
2. The message arrives at ``foo.example``. The receiving mail server reads the
``DKIM-Signature`` headers, looks up the public keys for ``bar.example`` for
each of the specified selectors (e.g., ``mail._domainkey.bar.example``), and
verifies that at least one signature matches the message content.
3. With a valid signature, the receiver knows the message was authorized by
``bar.example`` and that the signed headers and body were not modified in
transit. If the content or signed headers were changed, the DKIM verification
fails. The use of selectors allows ``bar.example`` to rotate or migrate keys
without disrupting verification for previously sent messages.
Enabling DKIM Signing
~~~~~~~~~~~~~~~~~~~~~
Because DKIM signing is crucial for reliable mail delivery it is enabled by
default and without further configuration a DKIM keypair will be generated for
each :option:`mailserver.domains` (including :option:`mailserver.srs.domain`,
if set) based on :option:`mailserver.dkim.defaults
<mailserver.dkim.defaults.keyLength>`.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim.enable = true; # enabled by default
};
}
.. note::
If you set up NixOS Mailserver before the `25.11 release`_ your DKIM keys were
generated with 1024 bit RSA and we recommend replacing them with 2048 bit
RSA key material per `RFC8301 3.2`_.
.. _25.11 release: release-notes.html#nixos-25-11
.. _RFC8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2
.. _dkim-key-rotation:
DKIM Key Rotation
~~~~~~~~~~~~~~~~~
DKIM key rotation replaces a domain's signing keys to maintain
strong email authentication and support algorithm upgrades. Rotation is
essential for migrating away from weaker or deprecated algorithms.
Selectors allow multiple keys to coexist during the transition: a new key can
be deployed under a different selector while the old key remains valid for a
limited period to verify messages still in transit. Once all messages signed
with the old key have been delivered, the key can be safely retired, ensuring a
reliable migration without breaking verification.
1. Make the automatically generated key explicit
************************************************
First we need to make sure we keep the current DKIM key configured. If you were
relying on automatically generated keys before, you now need to start explicitly
defining that key, because explicit selector configuration takes precedence.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim.domains = {
"example.com".selectors = {
"${config.mailserver.dkim.defaults.selector}" = { };
};
};
};
}
2. Create the new DKIM keypair
******************************
Next we need to create a new DKIM key with a unique selector, you can
for example choose the current date. Without any settings passed a new
key will be generated from the current :option:`mailserver.dkim.defaults
<mailserver.dkim.defaults.keyLength>`, which should be sufficient.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim = {
enable = true;
domains."example.com".selectors = {
"${config.mailserver.dkim.defaults.selector}" = { };
"rsa-2026-03" = {
keyType = "rsa";
keyLength = 2048;
};
};
};
};
}
.. warning::
While DKIM does support Ed25519 keys (`RFC8463`_), many validators still lack
proper support and may treat Ed25519 key material as invalid. As a result,
mail signed only with Ed25519 DKIM keys may fail verification at some
receivers.
.. _RFC8463: https://datatracker.ietf.org/doc/html/rfc8463
Once this configuration is applied the new keypair will be generated below
:option:`mailserver.dkim.keyDirectory`, which defaults to ``/var/dkim``. The
mailserver then begins signing outgoing mail with this key, so that it is now
signing with two DKIM keys simultaneously.
To allow receiving servers to verify the new DKIM signature its
public key needs to be published into DNS. Look up the public key from
``/var/dkim/example.com.rsa-2026-03.txt`` and create the following DNS record by
substituting selector and public key.
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
rsa-2026-03._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=<public key>
.. note::
If you created an Ed25519 key, make sure to set the correct key type: ``k=ed25519``
Now wait for a few minutes and then check DNS propagation to show the value specified.
.. code-block:: console
$ nix-shell -p dig --command "dig @ns1.example.org TXT rsa-2026-03._domainkey.example.com"
You can use https://www.mail-tester.com to test the new DKIM signature passes
validation. They allow you to view the message source where you can check that
the correct number of ``DKIM-Signature`` keys are present in the mail header.
3. Stop signing with the old DKIM keypair
*****************************************
Once validation passes we need to stop signing with the old DKIM keypair, so
mail in transit eventually stops depending on the key material we want to rotate
out. Removing the selector will not remove the key material from disk, but it
will stop using it to sign outgoing mail.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim = {
enable = true;
domains."example.com".selectors = {
"rsa-2026-03" = {
keyType = "rsa";
keyLength = 2048;
};
};
}
}
}
Apply the configuration.
4. Remove the old DKIM selector from DNS
****************************************
.. warning::
Do not remove the DNS records for the old selector immediately. Keeping them
in place is essential to ensure that messages still in transit can be verified
and delivered successfully.
Mail delivery is not always instantaneous. In the worst case, multiple retries
over several days may be required. According to `RFC5321 4.5.4.1`_ delivery
should be retried for at least 4-5 days.
This means messages signed only with the old DKIM key could still be in transit
and rely on the old selector to verify their signatures. To ensure reliable
delivery, we recommend waiting **at least five days** before removing the old
DKIM selector from DNS.
.. _RFC5321 4.5.4.1: https://www.rfc-editor.org/rfc/rfc5321#section-4.5.4.1
+1 -1
View File
@@ -13,7 +13,7 @@ domain ``example.com`` and send mails with any address of this domain:
.. code:: nix
mailserver.loginAccounts = {
mailserver.accounts = {
"user@example.com" = {
aliases = [ "@example.com" ];
};
+35
View File
@@ -0,0 +1,35 @@
{
description = "NixOS configuration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05-small";
simple-nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-26.05";
simple-nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{
nixpkgs,
simple-nixos-mailserver,
...
}:
{
nixosConfigurations = {
hostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; # or aarch64-linux
modules = [
simple-nixos-mailserver.nixosModule
{
mailserver = {
enable = true;
# Check the setup guide if you have no idea how to continue
# from here!
};
}
];
};
};
};
}
+13 -25
View File
@@ -1,30 +1,18 @@
Nix Flakes
==========
Flakes
======
If you're using `flakes <https://wiki.nixos.org/wiki/Flakes>`__, you can use
the following minimal ``flake.nix`` as an example:
To use NixOS mailserver `Nix flakes`_, the following minimal ``flake.nix`` can
serve as an example to get started:
.. code:: nix
.. _Nix flakes: https://wiki.nixos.org/wiki/Flakes
{
description = "NixOS configuration";
.. literalinclude:: ./flakes.nix
:language: nix
inputs.simple-nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-20.09";
outputs = { self, nixpkgs, simple-nixos-mailserver }: {
nixosConfigurations = {
hostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
simple-nixos-mailserver.nixosModule
{
mailserver = {
enable = true;
# ...
};
}
];
};
};
};
}
Lock the inputs and deploy the system closure:
.. code-block:: console
nix flake lock
nixos-rebuild --target-host root@mail.example.com --flake .#hostname switch
+26 -24
View File
@@ -4,7 +4,7 @@ Full text search
By default, when your IMAP client searches for an email containing some
text in its *body*, dovecot will read all your email sequentially. This
is very slow and IO intensive. To speed body searches up, it is possible to
*index* emails with a plugin to dovecot, ``fts_flatcurve``.
*index* emails with the ``fts_flatcurve`` dovecot plugin.
Enabling full text search
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -20,48 +20,50 @@ To enable indexing for full text search here is an example configuration.
enable = true;
# index new email as they arrive
autoIndex = true;
enforced = "body";
# only query index
fallback = false;
};
};
}
The ``enforced`` parameter tells dovecot to fail any body search query that cannot
use an index. This prevents dovecot to fall back to the IO-intensive brute
force search.
Disabling the :option:`mailserver.fullTextSearch.fallback` option tells dovecot
to fail any body search query that cannot use an index. This prevents Dovecot to
fall back to the IO-intensive brute force search.
If you set ``autoIndex`` to ``false``, indices will be created when the IMAP client
issues a search query, so latency will be high.
If you set :option:`mailserver.fullTextSearch.autoIndex` to ``false``, indices
will be created when the IMAP client issues a search query, so latency will
be high.
Resource requirements
~~~~~~~~~~~~~~~~~~~~~~~~
Indices created by the full text search feature can take more disk
space than the emails themselves. By default, they are kept in the
emails location. When enabling the full text search feature, it is
recommended to move indices in a different location, such as
(``/var/lib/dovecot/indices``) by using the option
``mailserver.indexDir``.
Indices created by the full text search feature can take more disk space than
the emails themselves. By default, they are kept within the maildir. When
enabling the full text search feature, it is recommended to move indices in a
different location, such as (``/var/lib/dovecot/indices``) by configuring
:option:`mailserver.indexDir`.
.. warning::
When the value of the ``indexDir`` option is changed, all dovecot
indices needs to be recreated: clients would need to resynchronize.
When the value of the :option:`mailserver.indexDir` option is changed, all
dovecot indices needs to be recreated: clients would need to resynchronize.
Indexation itself is rather resouces intensive, in CPU, and for emails with
Indexation itself is rather resource intensive, in CPU, and for emails with
large headers, in memory as well. Initial indexation of existing emails can take
hours. If the indexer worker is killed or segfaults during indexation, it can
be that it tried to allocate more memory than allowed. You can increase the memory
limit by eg ``mailserver.fullTextSearch.memoryLimit = 2000`` (in MiB).
hours. If the indexer worker is killed or segfaults during indexation, it can be
that it tried to allocate more memory than allowed. You can increase the default
memory limit through :option:`mailserver.fullTextSearch.memoryLimit`.
Mitigating resources requirements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can:
* exclude some headers from indexation with ``mailserver.fullTextSearch.headerExcludes``
* disable expensive token normalisation in ``mailserver.fullTextSearch.filters``
* disable automatic indexation for some folders with
``mailserver.fullTextSearch.autoIndexExclude``. Folders can be specified by
name (``"Trash"``), by special use (``"\\Junk"``) or with a wildcard.
* exclude some headers from indexation with :option:`mailserver.fullTextSearch.headerExcludes`
* disable expensive token normalisation in :option:`mailserver.fullTextSearch.filters`
* disable automatic indexation for individual mailboxes by overriding
`fts_autoindex`_ on the mailbox level. This is exposed via
:option:`mailserver.mailboxes`, where all default mailboxes are defined.
.. _fts_autoindex: https://doc.dovecot.org/main/core/plugins/fts.html#fts_autoindex
+2 -2
View File
@@ -38,8 +38,8 @@ You can then run the testsuite via
$ nix flake check -L
Since Nix doesn't garantee your machine have enough resources to run
all test VMs in parallel, some tests can fail. You would then haev to
Since Nix doesn't guarantee your machine have enough resources to run
all test VMs in parallel, some tests can fail. You would then have to
run tests manually. For instance:
::
+17 -6
View File
@@ -21,24 +21,35 @@ Welcome to NixOS Mailserver's documentation!
options
migrations
.. toctree::
:maxdepth: 1
:caption: Account backends
ldap
.. toctree::
:maxdepth: 1
:caption: Features
dkim
fts
ldap
srs
.. toctree::
:maxdepth: 0
:caption: How-to
backup-guide
add-radicale
add-roundcube
rspamd-tuning
flakes
autodiscovery
backup-guide
flakes
rspamd-tuning
.. toctree::
:maxdepth: 0
:caption: Integrations
radicale
roundcube
Indices and tables
==================
+12
View File
@@ -0,0 +1,12 @@
{
mailserver = {
ldap = {
attributes = {
uuid = "entryUUID";
username = "uid";
password = "userPassword";
mail = "mail";
};
};
};
}
+17
View File
@@ -0,0 +1,17 @@
{
mailserver = {
ldap = {
enable = true;
uris = [
"ldaps://ldap1.example.com"
"ldaps://ldap2.example.com"
];
bind = {
dn = "cn=mail,dc=example=dc=com";
passwordFile = "/run/keys/ldap-bind-pw";
};
base = "ou=users,dc=example,dc=com";
scope = "one";
};
};
}
+92 -10
View File
@@ -1,14 +1,96 @@
LDAP Support
============
.. _ldap-top:
It is possible to manage mail user accounts with LDAP rather than with
the option `loginAccounts <options.html#mailserver-loginaccounts>`_.
LDAP
====
All related LDAP options are described in the `LDAP options section
<options.html#mailserver-ldap>`_ and the `LDAP test
<https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/tests/ldap.nix>`_
provides a getting started example.
LDAP (Lightweight Directory Access Protocol) is a protocol for accessing and
managing a centralized directory of user and group information. It can be used
to authenticate users and provide a single source of truth for email accounts
and aliases across mail services.
.. note::
The LDAP support can not be enabled if some accounts are also defined with ``mailserver.loginAccounts``.
Requirements
~~~~~~~~~~~~
To enable the LDAP integration the following requirements must be fulfilled:
- Existing LDAP service (we currently only test against OpenLDAP)
- Bind credentials against LDAP with permissions to
- search for the acceptable set of users
- read the :option:`mailserver.ldap.attributes.password` attribute
- Each user entry must provide attributes that can serve as
- :option:`mailserver.ldap.attributes.mail` (primary mail address)
- :option:`mailserver.ldap.attributes.username` (login name)
- :option:`mailserver.ldap.attributes.password` (login password)
- :option:`mailserver.ldap.attributes.uuid` (stable identifier)
Features
~~~~~~~~
We currently have a basic feature set covering user accounts only and try to
follow best practices to simplify maintenance.
- Users authenticate with the username and password attribute
- Maildir storage paths are constructed using the uuid attribute
- Primary mail address read from mail attribute
Limitations
~~~~~~~~~~~
Design choices
^^^^^^^^^^^^^^
These are intentional choices in how the mail server operates that affect the
LDAP integration.
- For mail address routing local accounts always take priority over LDAP accounts.
Planned
^^^^^^^
These are features we are interested in but require implementation,
documentation and tests.
- Aliases based on LDAP attributes
- Quotas based on LDAP attributes
Avoided
^^^^^^^
The following features will likely never be implemented, since they would
complicate the setup significantly.
- Domains based on LDAP entries (would require integration with everything we
already do for :option:`mailserver.domains`)
- Use of ``homeDirectory``, ``uid``, ``gid`` LDAP attributes (we are
committed to a virtual setup with one vmail user/uid/gid and UUID based home
directories)
- Declarative aliases through :option:`mailserver.aliases`. These are limited
to local accounts, because Postfix enforces sender ownership based on login
identity and does not consult virtual aliases for authorization.
Enabling LDAP support
~~~~~~~~~~~~~~~~~~~~~
Enable the LDAP integration by configuring an authenticated LDAP connection
and how to locate all users. The bind DN must be allowed to read the configured
password attribute, which may require additional configuration
.. literalinclude:: ./ldap-basic.nix
:language: nix
We provide sensible defaults for each attribute, that can be adapted to your
local setup.
.. literalinclude:: ./ldap-attrs.nix
:language: nix
Refer to our `LDAP test`_ for an complete example, and see the `LDAP options`_ section for all possible settings.
.. _LDAP options: options.html#mailserver-ldap
.. _LDAP test: https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/main/tests/ldap.nix
+197 -12
View File
@@ -5,10 +5,192 @@ 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.
The initial :option:`mailserver.stateVersion` value should be copied from the
setup guide that you used to initially set up your mail server. If in doubt you
can always initialize it at ``1`` and walk through all assertions, that might
apply to your setup.
NixOS 26.05
-----------
.. _migration-5:
#5 Sieve script directory migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Sieve scripts managed by users via ManageSieve were previously stored in
``/var/sieve`` (or via the now-removed option
``mailserver.sieveDirectory``). This setup partially mirrored the mail
directory structure in ``/var/vmail`` (``mailserver.storage.path``),
which proved to be fragile and error-prone.
Thanks to a `prior migration`_, we can now migrate these directories into each
users home directory, aligning with upstream recommendations — almost
nine years later.
.. _prior migration: #dovecot-mail-directory-migration
This migration is only required if you have :option:`mailserver.enableManageSieve` enabled.
1. If you are coming from ``25.11`` and are using LDAP, temporarily disable
:option:`mailserver.enableManageSieve` by setting it to ``false``, deploy,
and only then continue with this migration.
If you are not coming from ``25.11`` or are not using LDAP, continue with
this migration as is.
2. Copy the migration script to your mailserver and make it executable:
.. code-block:: console
cd /tmp
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-05.py
chmod +x nixos-mailserver-migration-05.py
3. Stop the ``postfix.service``.
.. code-block:: bash
systemctl stop postfix.service
4. Create a backup or snapshot of your ``mailserver.sieveDirectory``, so
you can restore should anything go wrong.
5. Run the migration script and pass your ``mailserver.sieveDirectory`` as argument:
The script should be run under the user who owns the ``mailserver.sieveDirectory``.
If run as root it will automatically switch into the appropriate user by itself.
The script will not modify your data unless called with ``--execute``.
The migration script finds all Sieve script directories in
``/var/sieve/`` (or any other ``mailserver.sieveDirectory``), for
example that of ``bob`` at ``/var/vmail/bob@example.com``.
It then takes ``bob@example.com`` and looks up the home directory
for ``bob``. Finally, it starts suggesting the necessary move
operations to migrate the Sieve directory to
``/var/vmail/example.com/bob/sieve`` and symlinks the active script
to ``/var/vmail/example.com/bob/.dovecot.sieve``.
Example:
.. code-block:: bash
./nixos-mailserver-migration-05.py \
/var/sieve
6. Review the script output.
The script can highlight various inconsistencies and problems, that should
be reviewed and acted upon.
If in doubt, join our community chat for help before applying any changes.
7. Rerun the command with ``--execute`` or run the proposed commands manually.
8. Update the ``mailserver.stateVersion`` to ``5``.
9. The previous Sieve directory (``mailserver.sieveDirectory``) should now be safe to delete.
10. If you temporarily disabled :option:`mailserver.enableManageSieve` in step 1,
re-enable it now by setting it back to ``true``.
.. _migration-4:
#4 Dovecot LDAP UUID-based home directories
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
LDAP Support in NixOS mailserver was introduced during the 23.11 release cycle
and came with a number of flaws that we are correcting now, three years later.
This particular migration is needed because up until now we were
relying on email addresses to construct the Dovecot home directory path
(``var/vmail/ldap/user@example.com``) which is fragile: addresses can
change, requiring manual homedir relocation. Switching to UUID-based homedirs
(``/var/vmail/ldap/<uuid>``) ensures stable, unique paths and applies well-known
best practices to mailserver management.
1. Copy the migration script to your mailserver and make it executable:
.. code-block:: console
cd /tmp
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-04.py
chmod +x nixos-mailserver-migration-04.py
2. Stop the ``dovecot.service``.
.. code-block:: bash
systemctl stop dovecot.service
3. Create a backup or snapshot of your :option:`mailserver.storage.path`, so
you can restore should anything go wrong.
4. Run the migration script and pass the required arguments to enable LDAP lookups:
The script should be run under the user who owns the :option:`mailserver.storage.path`.
If run as root it will automatically switch into the appropriate user by itself.
The script will not modify your data unless called with ``--execute``.
The migration script finds all Dovecot home directories in
``/var/vmail/ldap/`` (or any other :option:`mailserver.storage.path`),
for example that of bob at ``/var/vmail/ldap/bob@example.com``.
It then takes ``bob@example.com`` and queries the LDAP server for
``mail=bob@example.com`` to retrieve the UUID attribute. Finally
it starts suggesting the necessary move operations to arrive at
``/var/vmail/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092`` for bob.
Example:
.. code-block:: bash
./nixos-mailserver-migration-04.py \
--ldap-uri ldaps://ldap1.example.com
--ldap-bind-dn cn=mail,ou=accounts,dc=example,dc=com \
--ldap-bind-pw-file /run/keys/ldap-bind-pw \
--ldap-base ou=people,ou=accounts,dc=example,dc=com \
--ldap-scope sub \
--ldap-filter "(mail=%s)" \
--ldap-attr-uuid entryUUID \
/var/vmail
For the ``--ldap-attr-uuid`` parameter we expect a long-term stable
identifier, ideally a UUID field. The exact attribute name depends on your
LDAP implementation, for example:
- Authentik: ``uid`` `[1]`_
- Kanidm: ``uuid`` `[2]`_
- Keycloak ``entryUUID``
- OpenLDAP: ``entryUUID`` (`RFC4530`_)
If your LDAP provider isn't listed you can determine the correct
attribute by querying a user entry with ``ldapsearch``. Finally, configure
:option:`mailserver.ldap.attributes.uuid` accordingly.
Add ``--ldap-starttls`` if you use the the `ldap://` URI scheme and require
explicit TLS.
.. _[1]: https://docs.goauthentik.io/add-secure-apps/providers/ldap#users
.. _[2]: https://kanidm.github.io/kanidm/stable/integrations/ldap.html#data-mapping
.. _RFC4530: https://www.rfc-editor.org/rfc/rfc4530.html
5. Review the script output.
It's primary job is to determine the UUID for an LDAP account, so that it
can rename the Dovecot home directory from mail address to UUID within the
same directory.
The script can highlight various inconsistencies and problems, that should
be reviewed and acted upon.
If in doubt, join our community chat for help before applying any changes.
6. Rerun the command with ``--execute`` or run the proposed commands manually.
7. Update the ``mailserver.stateVersion`` to ``4``.
NixOS 25.11
-----------
@@ -37,19 +219,19 @@ 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
1. Copy the `migration script <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/main/migrations/nixos-mailserver-migration-03.py>`_ script to your mailserver
and make it executable:
.. code-block:: bash
wget https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/master/migrations/nixos-mailserver-migration-03.py
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-03.py
chmod +x nixos-mailserver-migration-03.py
2. Stop the ``dovecot2.service``.
2. Stop the ``dovecot.service``.
.. code-block:: bash
systemctl stop dovecot2.service
systemctl stop dovecot.service
3. Create a backup or snapshot of your ``mailserver.mailDirectory``, so you can restore
should anything go wrong.
@@ -59,17 +241,20 @@ For remediating this issue the following steps are required:
- ``--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
sudo -u virtualMail ./nixos-mailserver-migration-03.py --layout default /var/vmail
./nixos-mailserver-migration-03.py --layout default /var/vmail
5. Review the commands. They should be
- create a ``mail`` directory for each accounnt,
- create a ``mail`` directory for each account,
- move maildir contents from the parent directory into it,
- suggest removal of files that do not belong to the maildir
@@ -98,8 +283,8 @@ This migration is required if you both:
For remediating this issue the following steps are required:
1. Stop ``dovecot2.service``.
2. Move ``/var/vmail/ldap`` below your ``m̀ailserver.mailDirectory``.
1. Stop ``dovecot.service``.
2. Move ``/var/vmail/ldap`` below your ``mailserver.mailDirectory``.
3. Update the ``mailserver.stateVersion`` to ``2``.
#1 Initialization
+55
View File
@@ -0,0 +1,55 @@
{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
concatStrings
flip
mapAttrsToList
;
mailAccounts = config.mailserver.accounts;
htpasswd = pkgs.writeText "radicale.users" (
concatStrings (flip mapAttrsToList mailAccounts (mail: user: "${mail}+:${user.hashedPassword}\n"))
);
in
{
services.radicale = {
enable = true;
settings = {
auth = {
type = "htpasswd";
htpasswd_filename = "${htpasswd}";
htpasswd_encryption = "bcrypt";
};
};
};
services.nginx = {
enable = true;
virtualHosts = {
"cal.example.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:5232/";
extraConfig = ''
proxy_set_header X-Script-Name /;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_header Authorization;
'';
};
};
};
};
networking.firewall.allowedTCPPorts = [
80
443
];
}
+29
View File
@@ -0,0 +1,29 @@
Radicale
========
Radicale is a lightweight open-source CalDAV/CardDAV server that stores
calendars and contacts as plain files on the filesystem, enabling simple
self-hosted synchronization with standard clients.
Limitations
^^^^^^^^^^^
Radicale since the 3.x release (introduced in NixOS 20.09) does not support
traditional crypt() password hashes any longer. To establish access for
existing :option:`mailserver.accounts`, the hashing method used
for ``hashedPassword`` needs to be compatible with one of the available
`htpasswd_encryption`_ methods. Such hashes can for example be created using
.. code-block:: console
nix-shell -p mkpasswd --command "mkpasswd -m bcrypt"
.. _htpasswd_encryption: https://radicale.org/v3.html#htpasswd_encryption
Code
^^^^
Configuration contributed by Robert Schütz (@dotlambda).
.. literalinclude:: ./radicale.nix
:language: nix
+94 -3
View File
@@ -1,6 +1,97 @@
Release Notes
=============
NixOS 26.05
-----------
Features
^^^^^^^^
- :ref:`DKIM key management <dkim>` now supports multiple selectors per domain,
enabling :ref:`key rotation <dkim-key-rotation>`. Pre-created key material is
also supported. Existing automatically generated DKIM keys from before 25.11
use 1024-bit RSA and should be rotated. See :option:`mailserver.dkim.domains`.
- Certificate handling was simplified. We recommend using the NixOS
ACME module (``security.acme.certs``) and referencing a certificate
configuration by name. Alternatively, certificate and private key can be
managed manually. Configure either :option:`mailserver.x509.useACMEHost`
or :option:`mailserver.x509.certificateFile` and
:option:`mailserver.x509.privateKeyFile`. See the updated :ref:`setup guide
<setup-guide>` for a basic ACME HTTP-01 example.
- Local mail accounts can now use managed cleartext passwords. This integrates
well with secret management tools such as `agenix`_ and `sops-nix`_ while
avoiding password leakage into the world-readable Nix store. See
:option:`mailserver.accounts.<name>.passwordFile`.
- Blocked sender responses can now be customized. This is useful if you require GDPR
compliance. See :option:`mailserver.rejectSenderMessage`.
Security
^^^^^^^^
- TLSv1.2 cipher suites in Postfix now require `AEAD`_ and `ECDHE`_.
- Postfix and Dovecot now support negotiation of the ``SecP256r1MLKEM768``
key agreement mechanism. The `standardization process
<https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/>`__ is ongoing.
- Deprecated and obsolete TLS signature algorithms were removed from Postfix.
Sieve
^^^^^
- **Migration**: When ManageSieve is enabled, user-created Sieve scripts must
be migrated into their Dovecot home directory. See the :ref:`migration guide
<migration-5>`.
LDAP
^^^^
- **Migration**: Dovecot home directories for LDAP users must be migrated to
UUID-based directory names. The UUID attribute can be customized through
:option:`mailserver.ldap.attributes.uuid`. See the :ref:`migration guide
<migration-4>`.
- The LDAP configuration has been revamped. Option names have been simplified,
examples and documentation improved. The :ref:`LDAP documentation <ldap-top>`
was written from the ground up.
- The default LDAP login attribute changed from ``mail`` to ``uid``.
This allows users to login with their account name rather than
their email address, which is more convenient and consistent with
typical LDAP practices. The exact attribute can be customized through
:option:`mailserver.ldap.attributes.username`.
- The LDAP bind password is now read verbatim without trimming whitespace. Any
trailing newline is now preserved and may cause authentication failures.
- Local and LDAP accounts can now coexist. For overlapping accounts and addresses
the local account will always win.
Internals
^^^^^^^^^
- Dovecot has been updated from 2.3 to 2.4 and now relies on the structured settings option.
Deprecations
^^^^^^^^^^^^
The following integrations are deprecated and will be removed before the next
release:
- :option:`mailserver.borgbackup.enable`
- :option:`mailserver.backup.enable`
- :option:`mailserver.monitoring.enable`
.. _key rotation: dkim.html#dkim-key-rotation
.. _agenix: https://github.com/ryantm/agenix
.. _sops-nix: https://github.com/Mic92/sops-nix
.. _AEAD: https://en.wikipedia.org/wiki/Authenticated_encryption
.. _ECDHE: https://www.rfc-editor.org/rfc/rfc8422
NixOS 25.11
-----------
@@ -15,14 +106,14 @@ NixOS 25.11
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
instead. If you still require this feature you can re-enable it using
``mailserver.enableImap``, but it is scheduled for removal after the 25.11
release.
- SMTP server and client now support and prefer a hybrid key exchange
(X25519MLKEM768)
- SMTP access over STARTTLS on port ``587/tcp`` is now default disabled in line
with `RFC 8314 3.3`_. If you still require this feature you can renable it using
``mailserver.enableSubmission``.
with `RFC 8314 3.3`_. If you still require this feature you can re-enable it
using ``mailserver.enableSubmission``.
- DMARC reports are now sent with the ``noreply-dmarc`` localpart from the
system domain.
- DANE and MTA-STS are now validated for outgoing SMTP connections using
+17
View File
@@ -0,0 +1,17 @@
{ config, ... }:
{
services.nginx.virtualHosts.${config.services.roundcube.hostName} = {
forceSSL = false;
enableACME = false;
listen = [
{
addr = "127.0.0.1";
port = 8000;
}
];
};
services.caddy.virtualHosts."${config.services.roundcube.hostName}".extraConfig = ''
reverse_proxy localhost:8000
'';
}
+49
View File
@@ -0,0 +1,49 @@
{
config,
pkgs,
...
}:
{
services.roundcube = {
enable = true;
hostName = "webmail.example.com"; # the nginx vhost
package = pkgs.roundcube.withPlugins (
plugins: with plugins; [
# external plugins to be included
# https://search.nixos.org/packages?query=roundcubePlugins
persistent_login
]
);
# activate plugins
plugins = [
"persistent_login"
"managesieve" # built-in
];
dicts = with pkgs.aspellDicts; [
# https://search.nixos.org/packages?query=aspellDicts
en
];
maxAttachmentSize = config.mailserver.messageSizeLimit / 1024 / 1024;
extraConfig = ''
$config['imap_host'] = "ssl://${config.mailserver.fqdn}";
$config['smtp_host'] = "ssl://${config.mailserver.fqdn}";
$config['smtp_user'] = "%u";
$config['smtp_pass'] = "%p";
$config['managesieve_host'] = "tls://${config.mailserver.fqdn}";
$config['managesieve_port'] = 4190;
$config['managesieve_usetls'] = true;
'';
};
services.nginx.virtualHosts.${config.services.roundcube.hostName} = {
enableACME = true;
forceSSL = true;
};
networking.firewall.allowedTCPPorts = [
80
443
];
}
+26
View File
@@ -0,0 +1,26 @@
Roundcube
=========
Roundcube is a browser-based open-source webmail client that provides a
full-featured email interface with support for IMAP, SMTP, address books, and
extensible plugins.
Code
^^^^
The NixOS module for Roundcube integrates almost immediately with NixOS
mailserver, automatically configuring an Nginx virtual host and ACME-managed
TLS for secure webmail access; using other web servers may require additional
manual setup.
Once set up you can login with your login account credentials.
.. literalinclude:: ./roundcube.nix
:language: nix
To use a different reverse proxy, such as Caddy, bind Roundcube's Nginx virtual
host to ``127.0.0.1`` on a custom port and disable SSL and ACME, as the reverse
proxy will handle those.
.. literalinclude:: ./roundcube-caddy.nix
:language: nix
+5 -4
View File
@@ -44,9 +44,10 @@ details the meaning of each symbol. You can tune the weight if a symbol if neede
services.rspamd.locals = {
"groups.conf".text = ''
symbols {
"FORGED_RECIPIENTS" { weight = 0; }
}'';
symbols "FORGED_RECIPIENTS" {
weight = 0;
}
'';
};
Tune action thresholds
@@ -93,7 +94,7 @@ With an nginx reverse-proxy
If you have a secured nginx reverse proxy set on the host, you can use it to expose the socket.
**Keep in mind the UI is unsecured by default, you need to setup an authentication scheme**, for
exemple with `basic auth <https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/>`_:
example with `basic auth <https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/>`_:
.. code:: nix
+58
View File
@@ -0,0 +1,58 @@
{
config,
...
}:
{
imports = [
(builtins.fetchTarball {
# This is a quick and dirty way to import a NixOS mailserver release. What
# you should do long-term is use a proper dependency pinning tool like npins
# or flakes.
# URL to the tarball for the release matching your NixOS release
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-26.05/nixos-mailserver-nixos-26.05.tar.gz";
# Hash of the unpacked tarball, run the following command to retrieve it
# release="nixos-26.05" nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
sha256 = "0000000000000000000000000000000000000000000000000000";
})
];
# https://letsencrypt.org/repository/#let-s-encrypt-subscriber-agreement
security.acme.acceptTerms = true;
# Allow incoming HTTP connections
networking.firewall.allowedTCPPorts = [ 80 ];
# Enable ACME HTTP-01 challenge with nginx
services.nginx = {
enable = true;
virtualHosts.${config.mailserver.fqdn}.enableACME = true;
};
mailserver = {
enable = true;
stateVersion = 5;
fqdn = "mail.example.com";
domains = [ "example.com" ];
# Reference the existing ACME configuration created by nginx
x509.useACMEHost = config.mailserver.fqdn;
# A list of all login accounts. To create the password hashes, use
# nix-shell -p mkpasswd --run 'mkpasswd -s'
accounts = {
"user1@example.com" = {
# Reads the password hash from a file on the server
hashedPasswordFile = "/a/file/containing/a/hashed/password";
# Additional addresses delivered to this mailbox
aliases = [ "postmaster@example.com" ];
};
"user2@example.com" = {
# Provides the password hash inline
hashedPassword = "$y$j9T$JqqefR6flaaJBRjD4KVZc1$QM6h4Spr5.yn/FuIT.ydTV22daEbiVd8ZprV/POtPgB";
};
};
};
}
+230 -163
View File
@@ -1,3 +1,5 @@
.. _setup-guide:
Setup Guide
===========
@@ -5,241 +7,306 @@ Mail servers can be a tricky thing to set up. This guide is supposed to
run you through the most important steps to achieve a 10/10 score on
`<https://mail-tester.com>`_.
What you need is:
Requirements
~~~~~~~~~~~~
- a server running NixOS with a public IP
- a domain name.
To set up a self-hosted mail server, you need the following:
* Small (e.g. 1C/2G) server running NixOS
* Stable IPv4 and - strongly recommended - IPv6 addresses
* Ability to configure a Reverse DNS (PTR record) for your IP addresses
* Access to SMTP traffic on port 25/tcp - some hosters make you ask for this
* A registered domain name with DNS record management access
Once these requirements are in place, you can begin setting up your selfhosted
mailserver.
.. note::
In the following, we consider a server with the public IP ``1.2.3.4``
and the domain ``example.com``.
Below we'll assume that your server got assigned the public IP addresses
``192.0.2.1`` (IPv4) and ``2001:db8::1`` (IPv6) and that you control the
``example.com`` domain.
First, we will set the minimum DNS configuration to be able to deploy
an up and running mail server. Once the server is deployed, we could
then set all DNS entries required to send and receive mails on this
server.
Configure forward DNS records
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Setup DNS A/AAAA records for server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Here we set up ``mail.example.com`` as the forward hostname for your mail server
to point to the IP addresses allocated to the server. This allows reaching
the server under this name and to reference it later in MX records for mail
delivery.
Add DNS records to the domain ``example.com`` with the following
entries
Now edit the ``example.com`` zone and create the following DNS records:
==================== ===== ==== =============
Name (Subdomain) TTL Type Value
==================== ===== ==== =============
``mail.example.com`` 10800 A ``1.2.3.4``
``mail.example.com`` 10800 AAAA ``2001::1``
==================== ===== ==== =============
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
If your server does not have an IPv6 address, you must skip the `AAAA` record.
mail.example.com., 3600, A, 192.0.2.1
mail.example.com., 3600, AAAA, 2001:db8::1
You can check this with
.. note::
If your server does not have an IPv6 address, you must skip the ``AAAA``
record.
::
Verify DNS record propagation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ nix-shell -p bind --command "host -t A mail.example.com"
mail.example.com has address 1.2.3.4
Before we continue with the next step, we require that the forward DNS record
has propagated. For that it's best to check an authoritative nameserver for
``example.com`` so that we don't look at cached DNS records.
$ nix-shell -p bind --command "host -t AAAA mail.example.com"
mail.example.com has address 2001::1
.. code-block:: console
Note that it can take a while until a DNS entry is propagated. This
DNS entry is required for the Let's Encrypt certificate generation
(which is used in the below configuration example).
# Find the authoritative nameservers for example.com
$ nix-shell -p dig --command "dig NS example.com +short"
ns1.example.org.
ns2.example.org.
# Query the A record from an authoritative nameserver
$ nix-shell -p dig --command "dig @ns1.example.org A mail.example.com +short"
192.0.2.1
# Query the AAAA record from an authoritative nameserver
$ nix-shell -p dig --command "dig @ns1.example.org AAAA mail.example.com +short"
2001:db8::1
DNS propagation usually takes a few minutes, so you might need to retry these
queries. Once the IP addresses appear you can continue with the next step.
Setup the server
~~~~~~~~~~~~~~~~
The following describes a server setup that is fairly complete. Even
though there are more possible options (see the `NixOS Mailserver
options documentation <options.html>`_), these should be the most
common ones.
The following configuration describes a fairly complete mail server, capable
of sending and receiving mail for statically configured accounts. It includes
encrypted SMTP and IMAP services for secure delivery and retrieval, and relies
on ACME HTTP-01 to automatically obtain and maintain a TLS certificate.
.. code:: nix
While `more options`_ are available, the configuration below covers the most
common settings to get your mail server up and running.
{ config, pkgs, ... }: {
imports = [
(builtins.fetchTarball {
# Pick a release version you are interested in and set its hash, e.g.
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.11/nixos-mailserver-nixos-25.11.tar.gz";
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command:
# release="nixos-25.11"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
sha256 = "0000000000000000000000000000000000000000000000000000";
})
];
.. _more options: options.html
mailserver = {
enable = true;
stateVersion = 3;
fqdn = "mail.example.com";
domains = [ "example.com" ];
.. literalinclude:: ./setup-example.nix
:language: nix
# A list of all login accounts. To create the password hashes, use
# nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
loginAccounts = {
"user1@example.com" = {
hashedPasswordFile = "/a/file/containing/a/hashed/password";
aliases = ["postmaster@example.com"];
};
"user2@example.com" = { ... };
};
After a ``nixos-rebuild switch`` your server should be running all the necessary
mail services.
# Use Let's Encrypt certificates. Note that this needs to set up a stripped
# down nginx and opens port 80.
certificateScheme = "acme-nginx";
};
security.acme.acceptTerms = true;
security.acme.defaults.email = "security@example.com";
}
Configure DNS records
~~~~~~~~~~~~~~~~~~~~~
After a ``nixos-rebuild switch`` your server should be running all
mail components.
Reverse DNS
^^^^^^^^^^^
Setup all other DNS requirements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Earlier, we configured forward DNS from your hostname to your IP address. Now we
will configure reverse DNS so that your IP address points back to your hostname.
Set rDNS (reverse DNS) entry for server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
If your forward and reverse DNS do not match, many mail servers will reject or
flag your emails as spam, severely impairing delivery.
Wherever you have rented your server, you should be able to set reverse
DNS entries for the IPs you own:
Your server provider should allow you to configure reverse DNS (PTR record)
records for the IP addresses you control, typically through their control panel
or account management interface:
- Add an entry resolving IPv4 address ``1.2.3.4`` to ``mail.example.com``.
- Add an entry resolving IPv6 ``2001::1`` to ``mail.example.com``. Again, this
must be skipped if your server does not have an IPv6 address.
- Configure ``192.0.2.1`` to point to ``mail.example.com.``
- Configure ``2001:db8::1`` to point to ``mail.example.com.``, if you have IPv6
addressing
Alternatively, if you interact with a proper DNS setup, the actual DNS records
look like this:
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
1.2.0.192.in-addr.arpa., 86400, PTR, mail.example.com.
1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa., 86400, PTR, mail.example.com.
.. note::
Reverse DNS uses reverse notation for naming its records:
* IPv4 reverses the order of the octets and appends ``in-addr.arpa.``, so
``192.0.2.1`` becomes ``1.2.0.192.in-addr.arpa.``
* IPv6 fully expands the address and reverses each hex digit before
concatenating it with dots and appending ``ip6.arpa.``
.. code-block:: console
nix-shell -p haskellPackages.ip6addr --command "ip6addr --ptr 2001:db8::1"
1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.IP6.ARPA.
.. warning::
We don't recommend setting up a mail server if you are not able to
set a reverse DNS on your public IP because sent emails would be
mostly marked as spam. Note that many residential ISP providers
don't allow you to set a reverse DNS entry.
We don't recommend setting up a mail server if you are unable to configure
reverse DNS on your public IP addresses because mails would inevitable be
marked as spam. Note that many residential ISP providers don't allow you to
set a reverse DNS entry and prohibit sending mail through policy blocklists
like Spamhaus PBL.
You can check this with
DNS propagation often isn't instant, so verify before continuing:
::
.. code-block:: console
$ nix-shell -p bind --command "host 1.2.3.4"
4.3.2.1.in-addr.arpa domain name pointer mail.example.com.
$ nix-shell -p dig --command "dig -x 192.0.2.1 +short"
mail.example.com.
$ nix-shell -p bind --command "host 2001::1"
1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.2.ip6.arpa domain name pointer mail.example.com.
Note that it can take a while until a DNS entry is propagated.
Set a ``MX`` record
^^^^^^^^^^^^^^^^^^^
$ nix-shell -p dig --command "dig -x 2001:db8::1 +short"
mail.example.com.
Add a ``MX`` record to the domain ``example.com``.
MX record
^^^^^^^^^
================ ==== ======== =================
Name (Subdomain) Type Priority Value
================ ==== ======== =================
example.com MX 10 mail.example.com
================ ==== ======== =================
The MX record instructs other mailservers where to deliver mail for a domain
name.
You can check this with
Create the MX record for ``example.com`` to point to the hostname of the server.
::
.. csv-table::
:header: "Name", "TTL", "Priority", "Type", "Value"
:widths: 30, 10, 10, 10, 50
$ nix-shell -p bind --command "host -t mx example.com"
example.com mail is handled by 10 mail.example.com.
example.com., 3600, MX, 10, mail.example.com.
Note that it can take a while until a DNS entry is propagated.
The priority field determines the order when multiple servers are configured.
It is not important in this scenario but setting a value is mandatory and 10
leaves some wiggle room below and above, should you ever need that.
Set a ``SPF`` record
^^^^^^^^^^^^^^^^^^^^
.. code-block:: console
Add a `SPF <https://en.wikipedia.org/wiki/Sender_Policy_Framework>`_
record to the domain ``example.com``.
$ nix-shell -p dig --command "dig @ns1.example.org MX example.com +short"
10 mail.example.com.
================ ===== ==== ================================
Name (Subdomain) TTL Type Value
================ ===== ==== ================================
example.com 10800 TXT `v=spf1 a:mail.example.com -all`
================ ===== ==== ================================
SPF record
^^^^^^^^^^
You can check this with
With `SPF`_ we can specify which mail servers are authorized to send mail on
behalf of a domain name.
::
.. _SPF: https://en.wikipedia.org/wiki/Sender_Policy_Framework
$ nix-shell -p bind --command "host -t TXT example.com"
example.com descriptive text "v=spf1 a:mail.example.com -all"
The SPF record is TXT record and we can tie it in with the MX record we created
in the previous step. Finishing with ``-all`` indicates that without any match
the mail should be rejected.
Note that it can take a while until a DNS entry is propagated.
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
Set ``DKIM`` signature
^^^^^^^^^^^^^^^^^^^^^^
example.com., 86400, TXT, v=spf1 mx -all
On your server, the ``rspamd`` systemd service generated a file
containing your DKIM public key in the file
``/var/dkim/example.com.mail.txt``. The content of this file looks
like
.. code-block:: console
::
$ nix-shell -p dig --command "dig TXT example.com +short"
v=spf1 mx -all
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
"p=<really-long-key>" ) ; ----- DKIM key mail for nixos.org
where ``really-long-key`` is your public key.
DKIM record
^^^^^^^^^^^
Based on the content of this file, we can add a ``DKIM`` record to the
domain ``example.com``.
On system activation a `DKIM`_ keypair for ``example.com`` was generated. The
mail server uses this key to sign outgoing emails, allowing receiving servers to
verify the authenticity of the sender domain and ensuring that the signed parts
of the message have not been tampered with.
=========================== ===== ==== ================================================
Name (Subdomain) TTL Type Value
=========================== ===== ==== ================================================
mail._domainkey.example.com 10800 TXT ``v=DKIM1; k=rsa; s=email; p=<really-long-key>``
=========================== ===== ==== ================================================
.. _DKIM: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
You can check this with
Now, check ``/var/dkim/example.com.mail.txt``, which contains the proposed DNS
record for the ``mail`` DKIM selector.
::
.. code-block:: none
$ nix-shell -p bind --command "host -t txt mail._domainkey.example.com"
mail._domainkey.example.com descriptive text "v=DKIM1;p=<really-long-key>"
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7hSess/UgEjaaq/NDn5KtW2iZzYljhf45DH3tN/kqcJ04JJk/Z1rS7CMJQ/pYZSSnQOju0H25uOtODvhqXPDxDdtCyDSrx54z/38lGNtA76/iWy/ikjb9hEkb2k3HuKex3P4KhhOC1pytDEFnh/T2aBxPNOigc/cpqm1U9RbnAwvArtx9dgOAgiV8rOIgPgyrPw1B3cJG3hgFYU2"
"GwXMoiFQPgwm7bkjelmThqXozA7jFJfnYt49jjrIYCv8X/nQx9cNpVAv2852mhU/3uuy6sa4MPjT6RiK9BJCMyDnqSpTPCjIubL4VhGCuzp7RPBkayWnlaH0X8PWGq6BQ0eBwIDAQAB"
) ;
Note that it can take a while until a DNS entry is propagated.
Based on the content of this file, we can create the DKIM TXT record for the
``mail`` selector in the ``example.com`` zone. For the ``p=`` value, glue the
two long strings back together without any quotes and spaces and put them into
your record below.
Set a ``DMARC`` record
^^^^^^^^^^^^^^^^^^^^^^
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
Add a ``DMARC`` record to the domain ``example.com``.
mail._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB
======================== ===== ==== ====================
Name (Subdomain) TTL Type Value
======================== ===== ==== ====================
_dmarc.example.com 10800 TXT ``v=DMARC1; p=none``
======================== ===== ==== ====================
.. code-block:: console
You can check this with
$ nix-shell -p dig --command "dig @ns1.example.org TXT mail._domainkey.example.com +short"
"v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB"
::
$ nix-shell -p bind --command "host -t TXT _dmarc.example.com"
_dmarc.example.com descriptive text "v=DMARC1; p=none"
DMARC record
^^^^^^^^^^^^
Note that it can take a while until a DNS entry is propagated.
Finally, DMARC lets you define a policy for how strictly SPF and DKIM should be
checked and how to handle validation failures. For a new server, its important
to have a DMARC record in place, even if it doesnt enforce any actions yet,
because it improves deliverability by showing receiving servers that your domain
is properly managed and reducing the risk of email spoofing.
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
_dmarc.example.com., 86400, TXT, v=DMARC1; p=none;
Verify propagation one final time.
.. code-block:: console
$ nix-shell -p dig --command "dig @ns1.example.org TXT _dmarc.example.com +short"
"v=DMARC1; p=none"
Test your Setup
~~~~~~~~~~~~~~~
Write an email to your aunt (who has been waiting for your reply far too
long), and sign up for some of the finest newsletters the Internet has.
Maybe you want to sign up for the `SNM Announcement
List <https://www.freelists.org/list/snm>`__?
Write an email to your aunt — shes been waiting far too long for your reply,
and this is your chance to finally make her day. Or, if you prefer a less
emotional test, send a message to `mail-tester.com`_ to see how your outgoing
mail scores.
Besides that, you can send an email to
`mail-tester.com <https://www.mail-tester.com/>`__ and see how you
score, and let `mxtoolbox.com <http://mxtoolbox.com/>`__ take a look at
your setup, but if you followed the steps closely then everything should
be awesome!
You can also let `MXToolbox`_ take a peek at your setup. If you followed the
steps carefully, everything should be working perfectly!
Next steps (optional)
~~~~~~~~~~~~~~~~~~~~~
.. _mail-tester.com: https://mail-tester.com/
.. _MXToolbox: https://mxtoolbox.com/
Take a look through our `Advanced Configurations <advanced-configurations.html>`_.
Join the community
~~~~~~~~~~~~~~~~~~
The community has a lively chat room on Matrix at `#nixos-mailserver:nixos.org`_
where you can ask questions, get help, share ideas, or discuss contributions.
.. _#nixos-mailserver:nixos.org: https://matrix.to/#/#nixos-mailserver:nixos.org
Next steps
~~~~~~~~~~
Your server scored perfect results already, so these steps are entirely
optional.
Are you feeling adventurous? Dive into our `advanced configurations`_ to explore
additional features and capabilities that let you fine-tune and extend your
mail setup.
If you want to take things even further, more elaborate testing services can
give you a clearer picture of your mail service and suggest ways to improve
it.
- `internet.nl`_ (supported by the Dutch Government)
- `MECSA`_ (supported by the European Commission)
Finally, you can also browse the full list of `options`_ provided by NixOS mailserver.
.. _advanced configurations: advanced-configurations.html
.. _options: options.html
.. _internet.nl: https://internet.nl/test-mail/
.. _MECSA: https://mecsa.jrc.ec.europa.eu/
Generated
+12 -12
View File
@@ -19,15 +19,15 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
@@ -43,11 +43,11 @@
]
},
"locked": {
"lastModified": 1763319842,
"narHash": "sha256-YG19IyrTdnVn0l3DvcUYm85u3PaqBt6tI6VvolcuHnA=",
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "7275fa67fbbb75891c16d9dee7d88e58aea2d761",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
@@ -79,16 +79,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1763553727,
"narHash": "sha256-4aRqRkYHplWk0mrtoF5i3Uo73E3niOWiUZU8kmPm9hQ=",
"lastModified": 1779622335,
"narHash": "sha256-ViA62qtL5za7V3d5I8OA9q9JcFhsVAiL5jVHwEclWqk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "094318ea16502a7a81ce90dd3638697020f030a2",
"rev": "705e9929918b43bd7b715dc0a878ac870449bb03",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable-small",
"ref": "nixos-26.05-small",
"repo": "nixpkgs",
"type": "github"
}
+12 -4
View File
@@ -4,7 +4,7 @@
inputs = {
flake-compat = {
# for shell.nix compat
url = "github:edolstra/flake-compat";
url = "github:NixOS/flake-compat";
flake = false;
};
git-hooks = {
@@ -12,7 +12,7 @@
inputs.flake-compat.follows = "flake-compat";
inputs.nixpkgs.follows = "nixpkgs";
};
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05-small";
blobs = {
url = "gitlab:simple-nixos-mailserver/blobs";
flake = false;
@@ -112,6 +112,7 @@
"logo\\.png"
"conf\\.py"
"Makefile"
".*\\.nix"
".*\\.rst"
];
buildInputs = [
@@ -149,12 +150,13 @@
checks.${system} = allTests // {
pre-commit = git-hooks.lib.${system}.run {
src = ./.;
package = pkgs.prek;
hooks = {
# docs
markdownlint = {
enable = true;
settings.configuration = {
# Max line length, doesn't seem to correclty account for lines containing links
# Max line length, doesn't seem to correctly account for lines containing links
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
MD013 = false;
};
@@ -166,9 +168,15 @@
files = "\\.rst$";
};
# spell checking
typos = {
enable = true;
settings.configPath = ".typos.toml";
};
# nix
deadnix.enable = true;
nixfmt-rfc-style.enable = true;
nixfmt.enable = true;
# python
pyright.enable = true;
+98 -37
View File
@@ -5,31 +5,62 @@
}:
let
mailserverRelease = "25.11";
mailserverRelease = "26.05";
nixpkgsRelease = lib.trivial.release;
releaseMismatch =
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease;
in
{
warnings = lib.optional releaseMismatch ''
You are using
warnings =
lib.optionals releaseMismatch [
''
You are using
NixOS Mailserver version ${mailserverRelease} and
Nixpkgs version ${nixpkgsRelease}.
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.
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.
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
If you insist then you can disable this warning by adding
mailserver.enableNixpkgsReleaseCheck = false;
mailserver.enableNixpkgsReleaseCheck = false;
to your configuration.
'';
to your configuration.
''
]
++ lib.optionals config.mailserver.borgbackup.enable [
''
`mailserver.borgbackup` will be removed after 26.05.
The borgbackup integration will be removed with the recommendation to
migrate to the upstream `services.borgbackup` module, which receives far
superior maintenance and testing.
NixOS manual: https://nixos.org/manual/nixos/stable/#module-borgbase
''
]
++ lib.optionals config.mailserver.backup.enable [
''
`mailserver.backup` will be removed after 26.05.
The rsnapshot integration will be removed due to lack of maintenance,
expertise and tests to make sure it still works. Please use the upstream
module directly instead.
''
]
++ lib.optionals config.mailserver.monitoring.enable [
''
`mailserver.monitoring` will be removed after 26.05.
The monit integration will be removed due to lack of maintenance,
expertise and tests to make sure it still works.
''
];
# We guard all assertions by requiring mailserver to be actually enabled
assertions = lib.optionals config.mailserver.enable (
@@ -38,33 +69,49 @@ in
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.x509.useACMEHost != null
-> config.mailserver.x509.certificateFile == null && config.mailserver.x509.privateKeyFile == null;
message = "Configuring an ACME certificate (`mailserver.x509.useACMEHost`) is not possible while also passing an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`).";
}
{
assertion = config.mailserver.extraVirtualAliases == { };
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
assertion =
config.mailserver.x509.useACMEHost != null
|| (
config.mailserver.x509.certificateFile != null && config.mailserver.x509.privateKeyFile != null
);
message = "Configure either an ACME certificate (`mailserver.x509.useACMEHost`) or pass an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`).";
}
]
++
lib.optionals (config.mailserver.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.
++ lib.optionals config.mailserver.dkim.enable (
lib.flatten (
lib.mapAttrsToList (
domain: domainAttrs:
lib.mapAttrsToList (selector: selectorAttrs: [
{
assertion =
selectorAttrs.keyFile != null -> (selectorAttrs.keyType == null && selectorAttrs.keyLength == null);
message = "${domain} DKIM selector ${selector} can only use either `keyType`, `keyLength` OR `keyFile` not both.";
}
]) domainAttrs.selectors
) config.mailserver.dkim.domains
)
)
++ lib.optionals (config.mailserver.ldap.enable && config.mailserver.storage.path != "/var/vmail") [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 2;
message = ''
Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.storage.path`.
Remediation:
- Stop the `dovecot.service`
- Move `/var/vmail/ldap` below your `mailserver.storage.path`
- Increase the `stateVersion` to 2.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information.
'';
}
]
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;
@@ -75,10 +122,24 @@ in
'';
}
]
++ lib.optionals (config.mailserver.certificateScheme != "acme") [
++ lib.optionals (config.mailserver.ldap.enable) [
{
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";
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 4;
message = ''
NixOS Mailserver requires migrating LDAP home directories to UUID scheme
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-uuid-based-home-directories for required migration steps.
'';
}
]
++ lib.optionals (config.mailserver.enableManageSieve) [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 5;
message = ''
NixOS Mailserver requires moving the Sieve script directories into Dovecot home directories.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#sieve-script-directory-migration for required migration steps.
'';
}
]
);
+4 -5
View File
@@ -16,7 +16,6 @@
{
config,
pkgs,
lib,
...
}:
@@ -67,15 +66,15 @@ let
borgScript = ''
export BORG_REPO=${repoLocation}
${cmdPreexec}
${passphraseFragment} ${pkgs.borgbackup}/bin/borg init ${extraInitArgs} --encryption ${encryptionFragment} || true
${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations}
${passphraseFragment} ${lib.getExe' config.services.borgbackup.package "borg"} init ${extraInitArgs} --encryption ${encryptionFragment} || true
${passphraseFragment} ${lib.getExe' config.services.borgbackup.package "borg"} create ${extraCreateArgs} ${compression} ::${name} ${locations}
${cmdPostexec}
'';
in
{
config = lib.mkIf (config.mailserver.enable && cfg.enable) {
environment.systemPackages = with pkgs; [
borgbackup
environment.systemPackages = [
config.services.borgbackup.package
];
systemd.services.borgbackup = {
+38 -37
View File
@@ -24,28 +24,20 @@
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";
rec {
withACME = cfg.x509.useACMEHost != null;
# 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"
x509CertificateFile =
if withACME then
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/fullchain.pem"
else
throw "unknown certificate scheme";
cfg.x509.certificateFile;
x509PrivateKeyFile =
if withACME then
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/key.pem"
else
cfg.x509.privateKeyFile;
passwordFiles =
let
@@ -53,11 +45,18 @@ in
in
lib.mapAttrs (
name: value:
if value.hashedPasswordFile == null then
if value.hashedPasswordFile != null then
value.hashedPasswordFile
else if value.hashedPassword != null then
builtins.toString (mkHashFile name value.hashedPassword)
else
value.hashedPasswordFile
) cfg.loginAccounts;
value.passwordFile
) cfg.accounts;
# Collect accounts with plain text passwords that require hashing
accountsWithPlaintextPasswordFiles = lib.filter (name: cfg.accounts.${name}.passwordFile != null) (
builtins.attrNames cfg.accounts
);
# Appends the LDAP bind password to files to avoid writing this
# password into the Nix store.
@@ -70,21 +69,23 @@ in
passwordFile,
destination,
}:
pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
pkgs.writeScript "append-ldap-bind-pwd-in-${name}"
# bash
''
#!${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}
'';
}
+17
View File
@@ -0,0 +1,17 @@
{
imports = [
./assertions.nix
./borgbackup.nix
./rsnapshot.nix
./clamav.nix
./monit.nix
./users.nix
./environment.nix
./networking.nix
./systemd.nix
./dovecot.nix
./postfix.nix
./rspamd.nix
./kresd.nix
];
}
+439 -352
View File
@@ -32,116 +32,88 @@ with (import ./common.nix {
});
let
inherit (lib)
attrNames
concatMapStringsSep
filterAttrs
mapAttrs'
mkForce
mkIf
mkMerge
nameValuePair
;
cfg = config.mailserver;
passwdDir = "/run/dovecot2";
passwdFile = "${passwdDir}/passwd";
userdbFile = "${passwdDir}/userdb";
# This file contains the ldap bind password
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
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
);
maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs";
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
genPasswdScript =
pkgs.writeScript "generate-password-file"
# bash
''
#!${pkgs.stdenv.shell}
# https://doc.dovecot.org/2.3/configuration_manual/home_directories_for_virtual_users/#ways-to-set-up-home-directory
# Mail directory below the home directory
dovecotMaildir =
"maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}"
+ (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}");
set -euo pipefail
postfixCfg = config.services.postfix;
if (! test -d "${passwdDir}"); then
mkdir "${passwdDir}"
chmod 755 "${passwdDir}"
fi
ldapConfig = pkgs.writeTextFile {
name = "dovecot-ldap.conf.ext.template";
text = ''
ldap_version = 3
uris = ${lib.concatStringsSep " " cfg.ldap.uris}
${lib.optionalString cfg.ldap.startTls ''
tls = yes
''}
tls_require_cert = hard
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
dn = ${cfg.ldap.bind.dn}
sasl_bind = no
auth_bind = yes
base = ${cfg.ldap.searchBase}
scope = ${mkLdapSearchScope cfg.ldap.searchScope}
${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) ''
user_attrs = ${cfg.ldap.dovecot.userAttrs}
''}
user_filter = ${cfg.ldap.dovecot.userFilter}
${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") ''
pass_attrs = ${cfg.ldap.dovecot.passAttrs}
''}
pass_filter = ${cfg.ldap.dovecot.passFilter}
'';
};
# Prevent world-readable password files, even temporarily.
umask 077
setPwdInLdapConfFile = appendLdapBindPwd {
name = "ldap-conf-file";
file = ldapConfig;
prefix = ''dnpass = "'';
suffix = ''"'';
passwordFile = cfg.ldap.bind.passwordFile;
destination = ldapConfFile;
};
prepend_scheme() {
case "$1" in
{*}*) printf '%s' "$1" ;;
*) printf '{CRYPT}%s' "$1" ;;
esac
}
genPasswdScript = pkgs.writeScript "generate-password-file" ''
#!${pkgs.stdenv.shell}
for f in ${
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.accounts)
}; do
if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!"
exit 1
fi
done
set -euo pipefail
cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: _:
if lib.elem name accountsWithPlaintextPasswordFiles then
"${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' config.services.dovecot2.package "doveadm"} pw)"}::::::"
else
"${name}:${"$(prepend_scheme \"$(head -n 1 ${passwordFiles."${name}"})\")"}::::::"
) cfg.accounts
)}
EOF
chown dovecot2:dovecot2 ${passwdFile}
if (! test -d "${passwdDir}"); then
mkdir "${passwdDir}"
chmod 755 "${passwdDir}"
fi
# Prevent world-readable password files, even temporarily.
umask 077
for f in ${
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)
}; do
if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!"
exit 1
fi
done
cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: _: "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
) cfg.loginAccounts
)}
EOF
cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value:
"${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}"
) cfg.loginAccounts
)}
EOF
'';
cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value:
# https://doc.dovecot.org/2.4.3/core/config/auth/databases/passwd_file.html
# https://doc.dovecot.org/2.4.3/core/plugins/quota.html#per-user-quota
# https://dovecot.org/mailman3/archives/list/dovecot@dovecot.org/thread/67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO/#67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO
"${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota/user/storage_size=${value.quota}"
) cfg.accounts
)}
EOF
chown dovecot2:dovecot2 ${userdbFile}
'';
junkMailboxes = builtins.attrNames (
lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes
lib.filterAttrs (_: v: v ? "special_use" && v.special_use == "\\Junk") cfg.mailboxes
);
junkMailboxNumber = builtins.length junkMailboxes;
# The assertion garantees there is exactly one Junk mailbox.
# The assertion guarantees there is exactly one Junk mailbox.
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
mkLdapSearchScope =
@@ -154,31 +126,31 @@ let
else
scope
);
ftsPluginSettings = {
fts = "flatcurve";
fts_languages = listToLine cfg.fullTextSearch.languages;
fts_tokenizers = listToLine [
"generic"
"email-address"
];
fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian
fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch;
fts_filters = listToLine cfg.fullTextSearch.filters;
fts_header_excludes = listToLine cfg.fullTextSearch.headerExcludes;
fts_autoindex = boolToYesNo cfg.fullTextSearch.autoIndex;
fts_enforced = cfg.fullTextSearch.enforced;
}
// (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude);
in
{
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = junkMailboxNumber == 1;
message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special use' flag set to 'Junk' (${builtins.toString junkMailboxNumber} have been found)";
message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special_use' flag set to '\\Junk' (${builtins.toString junkMailboxNumber} have been found)";
}
(
let
usersWithQuota = attrNames (
filterAttrs (_: account: account.quota != null) config.mailserver.accounts
);
in
{
assertion = !cfg.quota.enable -> usersWithQuota == [ ];
message = ''
Without quota support enabled, per-user quotas cannot be applied to the following accounts:
${concatMapStringsSep "\n" (account: "- ${account}") usersWithQuota}
Either remove per user quota settings or re-enable `mailserver.quota.enable`.
'';
}
)
];
warnings =
@@ -196,270 +168,385 @@ in
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.
security.acme.certs = lib.mkIf withACME {
${cfg.x509.useACMEHost} = {
reloadServices = [ "dovecot.service" ];
};
};
# Dovecot modules
environment.systemPackages = [
pkgs.dovecot_pigeonhole
]
++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
];
# For compatibility with python imaplib
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
services.dovecot2 = {
enable = true;
enableImap = cfg.enableImap || cfg.enableImapSsl;
enablePop3 = cfg.enablePop3 || cfg.enablePop3Ssl;
enablePAM = false;
enableQuota = true;
mailGroup = cfg.vmailGroupName;
mailUser = cfg.vmailUserName;
mailLocation = dovecotMaildir;
sslServerCert = certificatePath;
sslServerKey = keyPath;
enableDHE = lib.mkDefault false;
enableLmtp = true;
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [
"fts"
"fts_flatcurve"
package = pkgs.dovecot; # pin over stateVersion logic in nixox 26.05
enablePAM = mkForce false;
sieve.pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_spam")
];
protocols = lib.optional cfg.enableManageSieve "sieve";
pluginSettings = {
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);
# https://doc.dovecot.org/2.4.3/core/settings/syntax.html
# https://doc.dovecot.org/2.4.3/core/settings/types.html#boolean-list
settings = mkMerge [
({
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_config_version
dovecot_config_version = "2.4.3";
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_storage_version
dovecot_storage_version = "2.3.21.1";
sieve = {
extensions = [
"fileinto"
];
# server identity
hostname = cfg.fqdn;
scripts.after = builtins.toFile "spam.sieve" ''
require "fileinto";
# vmail user
mail_uid = cfg.storage.owner;
mail_gid = cfg.storage.group;
mail_access_groups = cfg.storage.group;
if header :is "X-Spam" "Yes" {
fileinto "${junkMailboxName}";
stop;
}
'';
pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
];
};
imapsieve.mailbox = [
{
name = junkMailboxName;
causes = [
"COPY"
"APPEND"
# authentication
auth_mechanisms = [
"plain"
"login"
];
before = ./dovecot/imap_sieve/report-spam.sieve;
}
{
name = "*";
from = junkMailboxName;
causes = [ "COPY" ];
before = ./dovecot/imap_sieve/report-ham.sieve;
}
# backend services
"service anvil" = {
"unix_listener anvil" = {
mode = "0660";
group = cfg.storage.group;
};
};
"service auth" = {
"unix_listener auth" = {
user = config.services.postfix.user;
group = config.services.postfix.group;
mode = "0660";
};
};
"service lmtp" = {
"unix_listener dovecot-lmtp" = {
user = config.services.postfix.user;
group = config.services.postfix.group;
mode = "0600";
};
user = cfg.storage.owner;
vsz_limit = "${toString cfg.lmtpMemoryLimit} MB";
};
# frontend services
"service imap-login" = mkIf (cfg.enableImap || cfg.enableImapSsl) {
"inet_listener imap" = {
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = if cfg.enableImap then 143 else 0;
};
"inet_listener imaps" = mkIf cfg.enableImapSsl {
port = 993;
ssl = true;
};
};
"service pop3-login" = mkIf (cfg.enablePop3 || cfg.enablePop3Ssl) {
"inet_listener pop3" = {
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = if cfg.enablePop3 then 110 else 0;
};
"inet_listener pop3s" = mkIf cfg.enablePop3Ssl {
port = 995;
ssl = true;
};
};
"service imap" = {
vsz_limit = "${toString cfg.imapMemoryLimit} MB";
};
# protocols
protocols = {
lmtp = true;
imap = cfg.enableImap || cfg.enableImapSsl;
pop3 = cfg.enablePop3 || cfg.enablePop3Ssl;
sieve = cfg.enableManageSieve;
};
"protocol lmtp" = {
mail_plugins = {
sieve = true;
};
};
"protocol imap" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
mail_plugins = {
imap_sieve = true;
};
};
"protocol pop3" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
};
# tls settings
ssl_server_cert_file = x509CertificateFile;
ssl_server_key_file = x509PrivateKeyFile;
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
ssl = "required";
ssl_min_protocol = "TLSv1";
ssl_server_prefer_ciphers = "client";
ssl_cipher_list = lib.concatStringsSep ":" [
# TLS1.3
"TLS_AES_128_GCM_SHA256"
"TLS_CHACHA20_POLY1305_SHA256"
"TLS_AES_256_GCM_SHA384"
# TLS1.2
# EC key material
"ECDHE-ECDSA-AES128-GCM-SHA256"
"ECDHE-ECDSA-CHACHA20-POLY1305"
"ECDHE-ECDSA-AES256-GCM-SHA384"
# RSA key material
"ECDHE-RSA-AES128-GCM-SHA256"
"ECDHE-RSA-CHACHA20-POLY1305"
"ECDHE-RSA-AES256-GCM-SHA384"
];
ssl_curve_list = lib.concatStringsSep ":" [
"X25519MLKEM768"
"X25519"
"SecP256r1MLKEM768"
"prime256v1"
"secp384r1"
];
# default user mailboxes
"namespace inbox" = {
inbox = true;
separator = cfg.hierarchySeparator;
}
// mapAttrs' (name: value: nameValuePair ''mailbox "${name}"'' value) cfg.mailboxes;
lda_mailbox_autosubscribe = true;
lda_mailbox_autocreate = true;
# subaddressing
recipient_delimiter = cfg.recipientDelimiter;
lmtp_save_to_detail_mailbox = cfg.lmtpSaveToDetailMailbox;
# sieve filtering
"sieve_script spamfilter" = {
# junk filter
path = pkgs.writeText "after.sieve" ''
require "fileinto";
if header :is "X-Spam" "Yes" {
fileinto "${junkMailboxName}";
stop;
}
'';
type = "after";
};
"sieve_script default" = {
# declarative
type = "default";
name = "default";
# TODO: Pre-compile Sieve scripts with 'sievec' (requires a Dovecot config in build sandbox)
path = "${
pkgs.runCommand "declarative-sieve-scripts" { } (
''
mkdir "$out"
''
+ lib.concatMapAttrsStringSep "\n" (_: value: ''
mkdir "$out/${value.name}"
cp -v "${builtins.toFile "default.sieve" value.sieveScript}" "$out/${value.name}/default.sieve"
'') (lib.filterAttrs (_: value: value.sieveScript != null) cfg.accounts)
)
}/%{user}/default.sieve";
};
"sieve_script personal" = {
# managesieve
type = "personal";
# Upstream default, but we want to be explicit about it
# https://doc.dovecot.org/main/core/plugins/sieve.html#script-storage-type-personal
active_path = "~/.dovecot.sieve";
path = "~/sieve";
};
sieve_extensions = {
fileinto = true;
};
sieve_global_extensions = {
"vnd.dovecot.pipe" = true;
};
sieve_plugins = {
sieve_imapsieve = true;
sieve_extprograms = true;
};
# imapsieve (spam/ham learning)
"mailbox ${junkMailboxName}" = {
"sieve_script spam" = {
cause = [
"APPEND"
"COPY"
];
path = ./dovecot/imap_sieve/report-spam.sieve;
type = "before";
};
};
"imapsieve_from ${junkMailboxName}" = {
"sieve_script ham" = {
cause = "copy";
path = ./dovecot/imap_sieve/report-ham.sieve;
type = "before";
};
};
mailbox_list_layout = cfg.storage.directoryLayout;
mailbox_list_utf8 = cfg.useUTF8FolderNames;
mail_driver = "maildir";
mail_path = "~/mail";
# declarative users
"userdb declarative" = {
driver = "passwd-file";
passwd_file_path = userdbFile;
fields = {
home = "${cfg.storage.path}/%{user | domain}/%{user | username}";
inherit (cfg.storage) uid gid;
mail_index_path = mkIf (cfg.indexDir != null) "${cfg.indexDir}/%{user | domain}/%{user | username}";
};
};
"passdb declarative" = {
driver = "passwd-file";
passwd_file_path = passwdFile;
};
})
(mkIf cfg.ldap.enable {
# ldap users
ssl_client_ca_file = cfg.ldap.caFile;
ssl_client_require_valid_cert = true;
ldap_version = 3;
ldap_uris = cfg.ldap.uris;
ldap_starttls = cfg.ldap.startTls;
ldap_auth_dn = cfg.ldap.bind.dn;
ldap_auth_dn_password = "</run/credentials/dovecot.service/ldap-bind-pw";
ldap_base = cfg.ldap.base;
ldap_scope = mkLdapSearchScope cfg.ldap.scope;
"userdb ldap" = {
driver = "ldap";
filter = cfg.ldap.dovecot.userFilter;
fields = {
home = "${cfg.storage.path}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
inherit (cfg.storage) uid gid;
mail_index_path = mkIf (
cfg.indexDir != null
) "${cfg.indexDir}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
};
ldap_connection_group = "ldap-userdb-conn";
};
"passdb ldap" = {
driver = "ldap";
filter = cfg.ldap.dovecot.passFilter;
bind = cfg.ldap.attributes.password == null;
fields = {
password = mkIf (cfg.ldap.attributes.password != null) "%{ldap:${cfg.ldap.attributes.password}}";
};
ldap_connection_group = "ldap-passdb-conn";
};
})
(mkIf cfg.quota.enable {
mail_plugins.quota = true;
"protocol imap".mail_plugins.imap_quota = true;
"service quota-status" = {
executable = toString [
"${config.services.dovecot2.package}/libexec/dovecot/quota-status"
"-p"
"postfix"
];
"unix_listener quota-status" = {
user = "postfix";
};
client_limit = 1;
vsz_limit = "${toString cfg.quotaStatusMemoryLimit} MB";
};
quota_status_success = "DUNNO";
quota_status_nouser = "DUNNO";
quota_status_overquota = "552 5.2.2 Mailbox is full";
# quota_storage_grace = "10M";
"quota user" = {
driver = "count";
storage_size = mkIf (cfg.quota.defaults.perUser != null) cfg.quota.defaults.perUser;
};
})
(mkIf cfg.fullTextSearch.enable (
{
mail_plugins = {
fts = true;
fts_flatcurve = true;
};
"service indexer-worker" = mkIf (cfg.fullTextSearch.memoryLimit != null) {
vsz_limit = "${toString cfg.fullTextSearch.memoryLimit} MB";
};
fts_autoindex = cfg.fullTextSearch.autoIndex;
fts_driver = "flatcurve";
fts_search_add_missing = "yes";
fts_search_read_fallback = cfg.fullTextSearch.fallback;
fts_header_excludes = lib.genAttrs cfg.fullTextSearch.headerExcludes (_: true);
"fts flatcurve" = {
flatcurve_substring_search = cfg.fullTextSearch.substringSearch;
};
# languages
language_filters = lib.genAttrs cfg.fullTextSearch.filters (_: true);
language_tokenizer_address_token_maxlen = 100; # default 250 too large for Xapian
}
# build languages from list, the first one becomes the default language
// lib.listToAttrs (
lib.imap0 (i: lang: {
name = "language ${lang}";
value = (if i == 0 then { default = true; } else { }) // {
language_tokenizers = [
"generic"
"email-address"
];
};
}) cfg.fullTextSearch.languages
)
))
(mkIf cfg.debug.dovecot {
mail_debug = true;
# https://doc.dovecot.org/2.4.3/core/config/events/filter.html#common-unified-filter-language
log_debug = "category=ssl OR category=auth";
})
];
mailboxes = cfg.mailboxes;
extraConfig = ''
#Extra Config
${lib.optionalString cfg.debug.dovecot ''
mail_debug = yes
auth_debug = yes
verbose_ssl = yes
''}
${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) ''
service imap-login {
inet_listener imap {
${
if cfg.enableImap then
''
port = 143
''
else
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
}
inet_listener imaps {
${
if cfg.enableImapSsl then
''
port = 993
ssl = yes
''
else
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
}
}
''}
${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) ''
service pop3-login {
inet_listener pop3 {
${
if cfg.enablePop3 then
''
port = 110
''
else
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
}
inet_listener pop3s {
${
if cfg.enablePop3Ssl then
''
port = 995
ssl = yes
''
else
''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''
}
}
}
''}
protocol imap {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
mail_plugins = $mail_plugins imap_sieve
}
service imap {
vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB
}
protocol pop3 {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
}
mail_access_groups = ${cfg.vmailGroupName}
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
ssl = required
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = no
ssl_curve_list = X25519:prime256v1:secp384r1
service lmtp {
unix_listener dovecot-lmtp {
group = ${postfixCfg.group}
mode = 0600
user = ${postfixCfg.user}
}
vsz_limit = ${builtins.toString cfg.lmtpMemoryLimit} MB
}
service quota-status {
inet_listener {
port = 0
}
unix_listener quota-status {
user = postfix
}
vsz_limit = ${builtins.toString cfg.quotaStatusMemoryLimit} MB
}
recipient_delimiter = ${cfg.recipientDelimiter}
lmtp_save_to_detail_mailbox = ${cfg.lmtpSaveToDetailMailbox}
protocol lmtp {
mail_plugins = $mail_plugins sieve
}
passdb {
driver = passwd-file
args = ${passwdFile}
}
userdb {
driver = passwd-file
args = ${userdbFile}
default_fields = \
home=${cfg.mailDirectory}/%{domain}/%{username} \
uid=${builtins.toString cfg.vmailUID} \
gid=${builtins.toString cfg.vmailUID}
}
${lib.optionalString cfg.ldap.enable ''
passdb {
driver = ldap
args = ${ldapConfFile}
}
userdb {
driver = ldap
args = ${ldapConfFile}
default_fields = \
home=${cfg.mailDirectory}/ldap/%{user} \
uid=${toString cfg.vmailUID} \
gid=${toString cfg.vmailUID} \
mail=maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}${
lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/ldap/%{user}"
}
}
''}
service auth {
unix_listener auth {
mode = 0660
user = ${postfixCfg.user}
group = ${postfixCfg.group}
}
}
auth_mechanisms = plain login
namespace inbox {
separator = ${cfg.hierarchySeparator}
inbox = yes
}
service indexer-worker {
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit * 1024 * 1024)}
''}
}
lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes
'';
};
systemd.services.dovecot = {
preStart = ''
${genPasswdScript}
''
+ (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
'';
reloadTriggers = lib.mkIf (!withACME) [
x509CertificateFile
x509PrivateKeyFile
];
serviceConfig = lib.optionalAttrs cfg.ldap.enable {
LoadCredential = [
"ldap-bind-pw:${cfg.ldap.bind.passwordFile}"
];
};
};
systemd.services.postfix.restartTriggers = [
genPasswdScript
]
++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]);
];
};
}
+6 -9
View File
@@ -26,14 +26,11 @@ let
in
{
config = lib.mkIf cfg.enable {
environment.systemPackages =
with pkgs;
[
dovecot
openssh
postfix
rspamd
]
++ (if cfg.certificateScheme == "selfsigned" then [ openssl ] else [ ]);
environment.systemPackages = [
config.services.dovecot2.package
pkgs.openssh
config.services.postfix.package
config.services.rspamd.package
];
};
}
+1 -2
View File
@@ -32,8 +32,7 @@ in
++ 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;
++ lib.optional cfg.enableManageSieve 4190;
};
};
}
-59
View File
@@ -1,59 +0,0 @@
# nixos-mailserver: a simple mail server
# Copyright (C) 2016-2018 Robin Raymond
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
let
cfg = config.mailserver;
in
{
config =
lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"))
{
services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") {
enable = true;
virtualHosts."${cfg.fqdn}" = {
serverName = cfg.fqdn;
serverAliases = cfg.certificateDomains;
forceSSL = true;
enableACME = true;
};
};
security.acme.certs."${cfg.acmeCertificateName}" = {
extraDomainNames = lib.mkIf (cfg.certificateScheme == "acme") cfg.certificateDomains;
reloadServices = [
"postfix.service"
"dovecot.service"
];
};
};
}
+92 -38
View File
@@ -51,7 +51,7 @@ let
to = name;
in
map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name)
) cfg.loginAccounts
) cfg.accounts
)
);
regex_valiases_postfix = mergeLookupTables (
@@ -62,7 +62,7 @@ let
to = name;
in
map (from: { "${from}" = to; }) value.aliasesRegexp
) cfg.loginAccounts
) cfg.accounts
)
);
@@ -75,7 +75,7 @@ let
to = name;
in
map (from: { "@${from}" = to; }) value.catchAll
) cfg.loginAccounts
) cfg.accounts
)
);
@@ -94,7 +94,7 @@ let
mergeLookupTables lookupTables;
# extra_valiases_postfix :: Map String [String]
extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
extra_valiases_postfix = attrsToLookupTable cfg.aliases;
# forwards :: Map String [String]
forwards = attrsToLookupTable cfg.forwards;
@@ -127,13 +127,18 @@ let
# denied_recipients_postfix :: [ String ]
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.accounts)
);
denied_recipients_file = builtins.toFile "denied_recipients" (
lib.concatStringsSep "\n" denied_recipients_postfix
);
reject_senders_postfix = map (sender: "${sender} REJECT") cfg.rejectSender;
reject_senders_postfix = map (
sender:
"${sender} REJECT${
lib.optionalString (cfg.rejectSenderMessage != "") " ${cfg.rejectSenderMessage}"
}"
) cfg.rejectSender;
reject_senders_file = builtins.toFile "reject_senders" (
lib.concatStringsSep "\n" reject_senders_postfix
);
@@ -204,20 +209,21 @@ let
server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
start_tls = ${if cfg.ldap.startTls then "yes" else "no"}
version = 3
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
tls_ca_cert_file = ${cfg.ldap.caFile}
tls_require_cert = yes
search_base = ${cfg.ldap.searchBase}
scope = ${cfg.ldap.searchScope}
search_base = ${cfg.ldap.base}
scope = ${cfg.ldap.scope}
bind = yes
bind_dn = ${cfg.ldap.bind.dn}
'';
# Enforce a mapping between SMTP user and envelope sender address
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
${commonLdapConfig}
query_filter = ${cfg.ldap.postfix.filter}
result_attribute = ${cfg.ldap.postfix.mailAttribute}
result_attribute = ${cfg.ldap.attributes.username}
'';
ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf";
appendPwdInSenderLoginMap = appendLdapBindPwd {
@@ -228,10 +234,11 @@ let
destination = ldapSenderLoginMapFile;
};
# Check whether a recipient address exists, before accepting mail for it
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
${commonLdapConfig}
query_filter = ${cfg.ldap.postfix.filter}
result_attribute = ${cfg.ldap.postfix.uidAttribute}
result_attribute = ${cfg.ldap.attributes.username}
'';
ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf";
appendPwdInVirtualMailboxMap = appendLdapBindPwd {
@@ -279,6 +286,17 @@ in
};
};
security.acme.certs = lib.mkIf withACME {
${cfg.x509.useACMEHost} = {
reloadServices = [ "postfix.service" ];
};
};
systemd.services.postfix.reloadTriggers = lib.mkIf (!withACME) [
x509CertificateFile
x509PrivateKeyFile
];
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
preStart = ''
${appendPwdInVirtualMailboxMap}
@@ -316,9 +334,6 @@ in
message_size_limit = cfg.messageSizeLimit;
# virtual mail system
virtual_uid_maps = "static:5000";
virtual_gid_maps = "static:5000";
virtual_mailbox_base = cfg.mailDirectory;
virtual_mailbox_domains = vhosts_file;
virtual_mailbox_maps = [
(mappedFile "valias")
@@ -358,14 +373,16 @@ in
# reject selected recipients
"check_recipient_access ${mappedFile "denied_recipients"}"
"check_recipient_access ${mappedFile "reject_recipients"}"
]
++ lib.optionals cfg.quota.enable [
# quota checking
"check_policy_service unix:/run/dovecot2/quota-status"
];
# The X509 private key followed by the corresponding certificate
smtpd_tls_chain_files = [
"${keyPath}"
"${certificatePath}"
"${x509PrivateKeyFile}"
"${x509CertificateFile}"
];
# TLS for incoming mail is optional
@@ -375,17 +392,13 @@ in
smtpd_tls_auth_only = true;
# TLS versions supported for the SMTP server
smtpd_tls_protocols = ">=TLSv1.2";
smtpd_tls_mandatory_protocols = ">=TLSv1.2";
smtpd_tls_protocols = ">=TLSv1";
smtpd_tls_mandatory_protocols = ">=TLSv1";
# Require ciphersuites that OpenSSL classifies as "High"
smtpd_tls_ciphers = "high";
smtpd_tls_mandatory_ciphers = "high";
# Exclude cipher suites with undesirable properties
smtpd_tls_exclude_ciphers = "SHA1, eNULL, aNULL";
smtpd_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL";
# Enable DNSSEC/DANE support for outgoing SMTP connections
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
smtp_dns_support_level = "dnssec";
@@ -399,13 +412,6 @@ in
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);
@@ -415,14 +421,52 @@ in
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"
]
];
baseline_postfix_settings = {
# Allow all TLSv1.3 cipher suites
Ciphersuites = concatStringsSep ":" [
"TLS_AES_256_GCM_SHA384"
"TLS_AES_128_GCM_SHA256"
"TLS_CHACHA20_POLY1305_SHA256"
];
# Full list: openssl list -tls-groups
# Restrict and prioritize the following curves in the given order
# Excludes curves that have no widespread support, so we don't bloat the handshake needlessly.
# https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves
Groups = mkGroupString [
[ "*X25519MLKEM768" ]
[ "*X25519" ]
[ "SecP256r1MLKEM768" ]
[
"P-256"
"P-384"
]
];
SignatureAlgorithms = concatStringsSep ":" [
# Full list: openssl list -tls-signature-algorithms
# Reduced to algorithms with key material supported in CA/B
# baseline requirements and excluding deprecated algorithms
# like SHA1.
# EcDSA certificates
# https://cabforum.org/working-groups/server/baseline-requirements/requirements/#71312-ecdsa
"ecdsa_secp256r1_sha256"
"ecdsa_secp384r1_sha384"
"ecdsa_secp521r1_sha512"
# RSA certificates
# https://cabforum.org/working-groups/server/baseline-requirements/requirements/#71311-rsa
"rsa_pss_rsae_sha256"
"rsa_pss_rsae_sha384"
"rsa_pss_rsae_sha512"
"rsa_pss_pss_sha256"
"rsa_pss_pss_sha384"
"rsa_pss_pss_sha512"
"rsa_pkcs1_sha256"
"rsa_pkcs1_sha384"
"rsa_pkcs1_sha512"
];
};
};
};
tls_config_name = "postfix";
@@ -431,6 +475,16 @@ in
tls_eecdh_auto_curves = [ ];
tls_ffdhe_auto_groups = [ ];
# Require AEAD & ECDHE for TLSv1.2.
tls_high_cipherlist = concatStringsSep ":" [
"ECDHE-ECDSA-AES256-GCM-SHA384"
"ECDHE-RSA-AES256-GCM-SHA384"
"ECDHE-ECDSA-AES128-GCM-SHA256"
"ECDHE-RSA-AES128-GCM-SHA256"
"ECDHE-ECDSA-CHACHA20-POLY1305"
"ECDHE-RSA-CHACHA20-POLY1305"
];
# As long as all cipher suites are considered safe, let the client use its preferred cipher
tls_preempt_cipherlist = false;
@@ -439,7 +493,7 @@ in
smtpd_tls_loglevel = "1";
smtpd_milters = smtpdMilters;
non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ];
non_smtpd_milters = lib.mkIf cfg.dkim.enable [ "unix:/run/rspamd/rspamd-milter.sock" ];
milter_protocol = "6";
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}";
};
+1 -1
View File
@@ -61,7 +61,7 @@ in
retain hourly ${toString cfg.backup.retain.hourly}
retain daily ${toString cfg.backup.retain.daily}
retain weekly ${toString cfg.backup.retain.weekly}
backup ${cfg.mailDirectory}/ localhost/
backup ${cfg.storage.path}/ localhost/
'';
};
};
+97 -29
View File
@@ -26,32 +26,86 @@ let
postfixCfg = config.services.postfix;
rspamdCfg = config.services.rspamd;
rspamdSocket = "rspamd.service";
rspamdPkg = config.services.rspamd.package;
rspamdUser = config.services.rspamd.user;
rspamdGroup = config.services.rspamd.group;
createDkimKeypair =
domain:
{
domain,
selector,
type,
bits,
...
}:
let
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
privkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
pubkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.txt";
in
pkgs.writeShellScript "dkim-keygen-${domain}" ''
if [ ! -f "${privateKey}" ]
pkgs.writeShellScript "dkim-keygen-${domain}-${selector}" ''
if [ ! -f "${privkey}" ]
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}"
${lib.getExe' rspamdPkg "rspamadm"} dkim_keygen ${
lib.cli.toCommandLineShellGNU { } {
inherit
domain
selector
type
bits
privkey
;
}
} > "${pubkey}"
chmod 0644 "${pubkey}"
echo "Generated key for domain ${domain} and selector ${selector}"
fi
'';
dkimDomains = lib.unique (cfg.domains ++ (lib.optionals cfg.srs.enable [ cfg.srs.domain ]));
mailDomains = lib.unique (
# primary mailserver domains
config.mailserver.domains
# all dkim domains, even extra domains specified
++ lib.attrNames cfg.dkim.domains
# and the srs domain, if one is configured
++ lib.optionals (cfg.srs.domain != null) [ cfg.srs.domain ]
);
dkimKeys = lib.concatMap (
domain:
let
configuredSelectors = config.mailserver.dkim.domains.${domain}.selectors or { };
finalSelectors =
if configuredSelectors == { } then
# synthesize default dkim key, if none configured
{
"${config.mailserver.dkim.defaults.selector}" = {
keyType = null;
keyLength = null;
keyFile = null;
};
}
else
configuredSelectors;
in
lib.mapAttrsToList (selector: settings: rec {
inherit domain selector;
keyFile = settings.keyFile;
keyPath = if keyFile != null then keyFile else "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
bits =
if settings.keyLength != null then
settings.keyLength
else
config.mailserver.dkim.defaults.keyLength;
type =
if settings.keyType != null then settings.keyType else config.mailserver.dkim.defaults.keyType;
}) finalSelectors
) mailDomains;
dkimKeysToGenerate = lib.filter (key: key.keyFile == null) dkimKeys;
dkimKeysByDomain = lib.groupBy (item: item.domain) dkimKeys;
in
{
config = lib.mkIf cfg.enable {
@@ -61,7 +115,7 @@ in
nativeBuildInputs = with pkgs; [ makeWrapper ];
}
''
makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \
makeWrapper ${lib.getExe' rspamdPkg "rspamc"} $out/bin/rspamc \
--add-flags "-h /run/rspamd/worker-controller.sock"
''
)
@@ -73,6 +127,7 @@ in
locals = {
"milter_headers.conf" = {
text = ''
use = [ "authentication-results" ];
extended_spam_headers = true;
'';
};
@@ -110,13 +165,31 @@ in
};
"dkim_signing.conf" = {
text = ''
enabled = ${lib.boolToString cfg.dkimSigning};
path = "${cfg.dkimKeyDirectory}/$domain.$selector.key";
selector = "${cfg.dkimSelector}";
enabled = ${lib.boolToString cfg.dkim.enable};
# Only sign explicitly configured domains
try_fallback = false;
# Allow for usernames w/o domain part
allow_username_mismatch = true;
# Don't normalize DKIM key selection for subdomains
use_esld = false;
domain {
${lib.concatStringsSep "\n\n" (
map (domain: ''
${domain} {
selectors [
${lib.concatStringsSep ",\n" (
map (selector: ''
{
path: "${selector.keyPath}";
selector: "${selector.selector}";
}'') dkimKeysByDomain.${domain}
)}
]
}
'') (lib.attrNames dkimKeysByDomain)
)}
}
'';
};
"dmarc.conf" = {
@@ -183,7 +256,7 @@ in
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally;
systemd.tmpfiles.settings."10-rspamd.conf" = {
"${cfg.dkimKeyDirectory}" = {
"${cfg.dkim.keyDirectory}" = {
d = {
# Create /var/dkim owned by rspamd user/group
user = rspamdUser;
@@ -204,9 +277,9 @@ in
{
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
}
(lib.optionalAttrs cfg.dkimSigning {
ExecStartPre = map createDkimKeypair dkimDomains;
ReadWritePaths = [ cfg.dkimKeyDirectory ];
(lib.optionalAttrs cfg.dkim.enable {
ExecStartPre = map createDkimKeypair dkimKeysToGenerate;
ReadWritePaths = [ cfg.dkim.keyDirectory ];
})
];
};
@@ -216,7 +289,7 @@ in
# default behaviour when called without a date.
# https://github.com/rspamd/rspamd/issues/4062
script = toString [
(lib.getExe' pkgs.rspamd "rspamadm")
(lib.getExe' rspamdPkg "rspamadm")
"dmarc_report"
"$(date -d 'yesterday' '+%Y%m%d')"
];
@@ -278,11 +351,6 @@ in
};
};
systemd.services.postfix = {
after = [ rspamdSocket ];
requires = [ rspamdSocket ];
};
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
};
}
+11 -42
View File
@@ -33,51 +33,20 @@ with (import ./common.nix {
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" ];
certificateDeps = lib.optionals withACME [
"acme-order-renew-${cfg.x509.useACMEHost}.service"
];
in
{
config = lib.mkIf cfg.enable {
# Create self signed certificate
systemd.services.mailserver-selfsigned-certificate =
lib.mkIf (cfg.certificateScheme == "selfsigned")
{
after = [ "local-fs.target" ];
script = ''
# Create certificates if they do not exist yet
dir="${cfg.certificateDirectory}"
fqdn="${cfg.fqdn}"
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
key="$dir/key-${cfg.fqdn}.pem";
cert="$dir/cert-${cfg.fqdn}.pem";
if [[ ! -f $key || ! -f $cert ]]; then
mkdir -p "${cfg.certificateDirectory}"
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) &&
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \
-days 3650 -out "$cert"
fi
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = true;
};
};
# Create maildir folder before dovecot startup
systemd.services.dovecot = {
wants = certificatesDeps;
after = certificatesDeps;
wants = certificateDeps;
after = certificateDeps;
preStart =
let
directories = lib.strings.escapeShellArgs (
[ cfg.mailDirectory ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir
[ cfg.storage.path ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir
);
in
''
@@ -86,20 +55,20 @@ in
# Prevent world-readable paths, even temporarily.
umask 007
mkdir -p ${directories}
chgrp "${cfg.vmailGroupName}" ${directories}
chgrp "${cfg.storage.group}" ${directories}
chmod 02770 ${directories}
'';
};
# Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work
systemd.services.postfix = {
wants = certificatesDeps;
wants = certificateDeps;
after = [
"dovecot.service"
]
++ lib.optional cfg.dkimSigning "rspamd.service"
++ certificatesDeps;
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service";
++ lib.optional cfg.dkim.enable "rspamd.service"
++ certificateDeps;
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkim.enable "rspamd.service";
};
};
}
+24 -87
View File
@@ -16,112 +16,49 @@
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
with config.mailserver;
let
vmail_user = {
name = vmailUserName;
isSystemUser = true;
uid = vmailUID;
home = mailDirectory;
createHome = true;
group = vmailGroupName;
};
virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
# Prevent world-readable paths, even temporarily.
umask 007
# Create directory to store user sieve scripts if it doesn't exist
if (! test -d "${sieveDirectory}"); then
mkdir "${sieveDirectory}"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}"
chmod 770 "${sieveDirectory}"
fi
# Copy user's sieve script to the correct location (if it exists). If it
# is null, remove the file.
${lib.concatMapStringsSep "\n" (
{ name, sieveScript }:
if lib.isString sieveScript then
''
if (! test -d "${sieveDirectory}/${name}"); then
mkdir -p "${sieveDirectory}/${name}"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
chmod 770 "${sieveDirectory}/${name}"
fi
cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve"
${sieveScript}
EOF
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
''
else
''
if (test -f "${sieveDirectory}/${name}/default.sieve"); then
rm "${sieveDirectory}/${name}/default.sieve"
fi
if (test -f "${sieveDirectory}/${name}.svbin"); then
rm "${sieveDirectory}/${name}/default.svbin"
fi
''
) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))}
'';
cfg = config.mailserver;
in
{
config = lib.mkIf enable {
config = lib.mkIf cfg.enable {
# assert that all accounts provide a password
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);
assertion =
lib.length (
lib.filter (value: value != null) [
acct.hashedPassword
acct.hashedPasswordFile
acct.passwordFile
]
) == 1;
message = "Login account ${acct.name} must provide exactly one of password file, hashed password, or hashed password file";
}) (lib.attrValues cfg.accounts);
# 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
lib.attrValues cfg.accounts
)
);
# set the vmail gid to a specific value
users.groups = {
"${vmailGroupName}" = {
gid = vmailUID;
};
users.groups.${cfg.storage.group} = {
inherit (cfg.storage) gid;
};
# define all users
users.users = {
"${vmail_user.name}" = lib.mkForce vmail_user;
};
systemd.services.activate-virtual-mail-users = {
wantedBy = [ "multi-user.target" ];
before = [ "dovecot.service" ];
serviceConfig = {
ExecStart = virtualMailUsersActivationScript;
};
enable = true;
users.users.${cfg.storage.owner} = lib.mkForce {
inherit (cfg.storage)
group
uid
;
name = cfg.storage.owner;
isSystemUser = true;
home = cfg.storage.path;
createHome = true;
};
};
}
@@ -113,6 +113,10 @@ def main(vmail_root: Path, layout: FolderLayout, dry_run: bool = True):
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(
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env nix-shell
#!nix-shell -i python3 -p "python3.withPackages (ps: with ps; [ ldap3 ])"
import argparse
import os
import sys
from pathlib import Path
from pwd import getpwnam
from typing import Literal, cast
from ldap3 import BASE, LEVEL, SUBTREE, Connection, Server
from ldap3.core.exceptions import LDAPException
LDAPSearchScope = Literal["BASE", "LEVEL", "SUBTREE"]
EXIT_OK = 0
EXIT_ERROR = 1
EXIT_LDAP_STARTTLS = 2
EXIT_LDAP_BIND = 3
GREEN = "32"
YELLOW = "33"
RED = "31"
BOLD = "1"
NO_COLOR = "NO_COLOR" in os.environ
def color(text, code):
if NO_COLOR:
return text
return f"\033[{code}m{text}\033[0m"
def check_user(vmail_root: Path):
owner = vmail_root.owner()
owner_uid = getpwnam(owner).pw_uid
if os.geteuid() == owner_uid:
return
try:
print(f"Trying to switch effective user id to {owner_uid} ({owner})")
os.seteuid(owner_uid)
return
except PermissionError:
print(
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)"
)
sys.exit(1)
def move(*, src: Path, dst: Path, dry_run: bool = True) -> bool:
print(f'mv "{src}" "{dst}"')
if not dry_run:
try:
src.rename(dst)
except OSError as exc:
print(f"Rename failed ({src=!s}, {dst=!s}): {exc}")
return False
return True
def main(
*,
vmail_root: Path,
ldap_uri: str,
ldap_starttls: bool,
ldap_bind_dn: str,
ldap_bind_pw: str,
ldap_base: str,
ldap_scope: LDAPSearchScope,
ldap_filter: str,
ldap_attr_uuid: str,
dry_run: bool = True,
verbose: bool = False,
):
# Begin with LDAP connection for fast feedback
server = Server(ldap_uri)
conn = Connection(server, ldap_bind_dn, ldap_bind_pw)
if ldap_starttls:
try:
if ldap_starttls:
conn.start_tls()
except LDAPException as exc:
print(color(f"LDAP connection setup failed: {exc!r}", RED))
sys.exit(EXIT_LDAP_STARTTLS)
if not conn.bind():
err = conn.result
print(
color(
f"""
LDAP bind failed for {ldap_bind_dn}@{ldap_uri}
Result: {err.get("result")} ({err.get("description")})
Message: {err.get("message")!r}""",
RED,
)
)
sys.exit(EXIT_LDAP_BIND)
# Find existing dovecot home directories and collect account identifier
print(
color(
f"\nEnumerate accounts based on existing home directories in {(vmail_root / 'ldap')!s}",
BOLD,
)
)
skipped = 0
accounts = set()
homedirs = vmail_root.glob("ldap/*")
for path in homedirs:
if not path.is_dir():
print(f"- Not a directory ({path=!s}) (skipping)")
skipped += 1
continue
elif not (path / "mail").is_dir():
print(f"- No maildir in home ({path=!s}) (skipping)")
skipped += 1
continue
account = path.name
accounts.add(account)
if verbose:
print(f"- Home directory found ({path=!s}, {account=})")
print(
color(
f"\nFinding matching LDAP entries to retrieve `{ldap_attr_uuid}` attribute",
BOLD,
)
)
no_entry = 0
multiple_entries = 0
plan = {}
for account in sorted(accounts):
filter = ldap_filter % account
conn.search(
search_base=ldap_base,
search_filter=filter,
search_scope=ldap_scope,
attributes=[ldap_attr_uuid],
)
if conn.response is None:
print(f"- LDAP search produced no result for {filter}")
count = len(conn.entries)
if count < 1:
print(f"- No LDAP entry found ({account=}, {filter=}) (skipping)")
no_entry += 1
continue
elif count > 1:
print(f"- Multiple LDAP entries found ({account=}, {filter=}) (skipping)")
multiple_entries += 1
continue
else:
entry = conn.entries[0]
uuid = str(entry[ldap_attr_uuid].value)
if verbose:
print(f"- LDAP entry mapped ({account=}, {uuid=})")
plan.update({account: uuid})
print(color("\nThe following operations will be executed:", BOLD))
moved = 0
moves_failed = 0
for src, dst in plan.items():
_src = vmail_root / "ldap" / src
_dst = vmail_root / "ldap" / dst
if not move(src=_src, dst=_dst, dry_run=dry_run):
moves_failed += 1
else:
moved += 1
print(
color(
"\nMigration summary",
BOLD,
)
)
if any([skipped, no_entry, multiple_entries, not accounts, moves_failed]):
print("""
We strongly recommend reviewing and remediating all potential issues before
running with `--execute`. Specific details can be found further up.""")
if moved:
print(f"""
- {color(f"{moved} home directories were migrated successfully.", GREEN)} {"(dry run)" if dry_run else ""}
This is great news, they are now UUID-based and will be immune to username changes!""")
if skipped and accounts:
print(f"""
- {color(f"{skipped} paths in {(vmail_root / 'ldap')!s} were skipped.", YELLOW)}
These were not a directory or did not contain a maildir. They should be
reviewed but can most likely be deleted.""")
if no_entry:
print(f"""
- {color(f"{no_entry} LDAP queries found no entry.", YELLOW)}
This could be a problem, because we cannot migrate home directories without
finding the LDAP entry and retrieving its {ldap_attr_uuid} field. In practice
this can happen if an LDAP account was deleted but its mail home directory
remained.""")
if multiple_entries:
print(f"""
- {color(f"{multiple_entries} LDAP queries returned multiple entries.", RED)}
This is a problem, because we cannot decide which LDAP entry owns the home
directory.""")
if not accounts:
print(f"""
- {color("No home directories were found.", RED)}
Make sure you are passing the correct `vmail_root` argument. It must match
your `mailserver.mailDirectory` setting.""")
if moves_failed:
print(f"""
- {color("{moves_failed} home directories could not be renamed", RED)}
No reason to panic, but the script tried to rename a home directory and that
triggered and error. Check further up what went wrong.""")
if dry_run:
print(f"\n{color('No changes were made.', YELLOW)}")
print("Run the script with `--execute` to apply the listed changes.")
sys.exit(EXIT_OK if moves_failed == 0 else EXIT_ERROR)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="""
NixOS Mailserver Migration #4: Dovecot LDAP UUID-based home directories
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-uuid-based-home-directory)
"""
)
parser.add_argument(
"vmail_root", type=Path, help="Path to the `mailserver.mailDirectory`"
)
parser.add_argument(
"--ldap-uri",
type=str,
required=True,
help="URI for your LDAP server; ldaps://ldap1.example.com (TLS) or ldap://ldap1.example.com (Plain)",
)
parser.add_argument(
"--ldap-starttls",
action="store_true",
help="Enable StartTLS on plain LDAP connections",
)
parser.add_argument(
"--ldap-bind-dn",
type=str,
required=True,
help="The distinguished user allow to bind and search the LDAP server",
)
parser.add_argument(
"--ldap-bind-pw-file",
type=Path,
required=True,
help="Path to a file containing the bind password for the LDAP DN",
)
parser.add_argument(
"--ldap-base",
type=str,
required=True,
help="Base DN below which to search for LDAP accounts",
)
parser.add_argument(
"--ldap-scope",
choices=[
"sub",
"base",
"one",
],
default="sub",
help="Scope relative to the base DN",
)
parser.add_argument(
"--ldap-filter",
default="(mail=%s)",
help="LDAP query that filters for an account by the name in /var/vmail/ldap/<name> field, e.g. mail=%%s or uid=%%s if the name is not an email address.",
)
parser.add_argument(
"--ldap-attr-uuid",
default="entryUUID",
help="UUID attribute that uniquely identifies an LDAP account across login name changes",
)
parser.add_argument(
"--execute", action="store_true", help="Actually perform changes"
)
parser.add_argument("--verbose", action="store_true", help="Print more details")
args = parser.parse_args()
if args.ldap_filter.count("%s") != 1:
print(
"The --ldap-filter argument must contain exactly one '%s' as a placeholder for the primary email address.",
)
sys.exit(1)
def read_ldap_bind_pw():
try:
with open(args.ldap_bind_pw_file) as fd:
return fd.read().strip()
except OSError as exc:
print(f"Unable to read LDAP bind password file: {exc}")
sys.exit(1)
ldap_bind_pw = None
if os.geteuid() == 0:
# if we're root, read before priv drop
ldap_bind_pw = read_ldap_bind_pw()
check_user(args.vmail_root)
if ldap_bind_pw is None:
ldap_bind_pw = read_ldap_bind_pw()
ldap_scope: LDAPSearchScope = cast(
LDAPSearchScope,
{
"sub": SUBTREE,
"base": BASE,
"one": LEVEL,
}[args.ldap_scope],
)
main(
vmail_root=args.vmail_root,
ldap_uri=args.ldap_uri,
ldap_starttls=args.ldap_starttls,
ldap_bind_dn=args.ldap_bind_dn,
ldap_bind_pw=ldap_bind_pw,
ldap_base=args.ldap_base,
ldap_scope=ldap_scope,
ldap_filter=args.ldap_filter,
ldap_attr_uuid=args.ldap_attr_uuid,
dry_run=not args.execute,
verbose=args.verbose,
)
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env nix-shell
#!nix-shell -i python3 -p python3
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from pwd import getpwnam
EXIT_OK = 0
EXIT_ERROR = 1
GREEN = "32"
YELLOW = "33"
RED = "31"
BOLD = "1"
NO_COLOR = "NO_COLOR" in os.environ
def color(text, code):
if NO_COLOR:
return text
return f"\033[{code}m{text}\033[0m"
def check_user(sieve_root: Path):
owner = sieve_root.owner()
owner_uid = getpwnam(owner).pw_uid
if os.geteuid() == owner_uid:
return
try:
print(f"Trying to switch effective user id to {owner_uid} ({owner})")
os.seteuid(owner_uid)
return
except PermissionError:
print(
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)"
)
sys.exit(1)
def doveadm_get_user_home(user: str) -> Path:
output = subprocess.check_output(
["doveadm", "user", "-f", "home", user], text=True, stderr=subprocess.DEVNULL
)
homedir = Path(output.strip())
return homedir
def move(src: Path, dst: Path, dry_run: bool = True) -> bool:
print(f'mv "{src}" "{dst}"')
if not dry_run:
try:
shutil.move(src, dst)
except OSError as exc:
print(f"Rename failed ({src=!s}, {dst=!s}): {exc}")
return False
return True
def symlink(target: Path, link: Path, dry_run: bool = True) -> bool:
print(f'ln --symbolic --relative "{target}" "{link}"')
if not dry_run:
try:
target_relative = target.relative_to(link.parent)
link.symlink_to(target_relative)
except (OSError, ValueError) as exc:
print(f"Symlinking failed ({target=!s}, {link=!s}): {exc}")
return False
return True
def main(sieve_root: Path, dry_run: bool = True):
print(
color(
f"\nFind accounts based on existing Sieve script directories in {sieve_root!s}",
BOLD,
)
)
skipped = 0
accounts = set()
for path in sieve_root.glob("*"):
if not path.is_dir():
print(f"- Not a directory ({path=!s}) (skipping)")
skipped += 1
continue
elif not set(path.glob("scripts/*.sieve")):
print(f"- No Sieve scripts in directory ({path=!s}) (skipping)")
skipped += 1
continue
account = path.name
accounts.add(account)
print(f"- Sieve directory found ({path=!s}, {account=})")
print(
color(
f"\nLookup home directory of accounts based on the remaining Sieve directories found in {sieve_root!s}",
BOLD,
)
)
lookup_failed = 0
homedirs = {}
for account in accounts:
try:
homedir = doveadm_get_user_home(account)
except subprocess.CalledProcessError as exc:
print(f"- Home directory lookup failed ({account=}): {exc}")
lookup_failed += 1
continue
print(f"- Home directory retrieved ({account=}, {homedir=!s})")
homedirs.update({account: homedir})
print(
color(
"\nEnumerate Sieve directories of accounts",
BOLD,
)
)
plan = {}
for account, homedir in homedirs.items():
sieve_src = sieve_root / account / "scripts"
sieve_dst = homedir / "sieve"
plan.update({sieve_src: sieve_dst})
active = sieve_root / account / "active.sieve"
link = homedir / ".dovecot.sieve"
# An account may have Sieve scripts but none enabled,
# e.g. an out-of-office auto-reply but currently in-office.
if not active.is_symlink():
print(
f"- Account has Sieve scripts but none enabled ({account=}, {active=!s})"
)
continue
active_relative = active.resolve().relative_to(sieve_src)
target = sieve_dst / active_relative
plan.update({target: link})
print(
f"- Account has Sieve scripts and one enabled ({account=}, {sieve_src=!s})"
)
print(color("\nThe following operations will be executed:", BOLD))
moved = 0
moves_failed = 0
for src, dst in plan.items():
if src.is_dir():
if not move(src=src, dst=dst, dry_run=dry_run):
moves_failed += 1
else:
moved += 1
else:
if not symlink(target=src, link=dst, dry_run=dry_run):
moves_failed += 1
else:
moved += 1
print(
color(
"\nMigration summary",
BOLD,
)
)
if any([skipped, lookup_failed, not accounts, moves_failed]):
print("""
We strongly recommend reviewing and remediating all potential issues before
running with `--execute`. Specific details can be found further up.""")
if moved:
print(f"""
- {color(f"{moved} Sieve script directories were migrated successfully.", GREEN)} {"(dry run)" if dry_run else ""}""")
if skipped and accounts:
print(f"""
- {color(f"{skipped} paths in {(sieve_root)!s} were skipped.", YELLOW)}
These were not a directory or did not contain a ./scripts directory.
They should be reviewed but can most likely be deleted.""")
if lookup_failed:
print(f"""
- {color(f"{lookup_failed} account lookups failed.", YELLOW)}
This could be a problem, because we cannot migrate the Sieve script
directory into the home directory without finding the owner of the
directory. In practice this can happen if an account was deleted but
its Sieve script directory remained.""")
if not accounts:
print(f"""
- {color("No Sieve script directories were found.", RED)}
Make sure you are passing the correct `sieve_root` argument. It must match
your `mailserver.sieveDirectory` setting. In practise this may also happen
if simply no account has Sieve scripts.""")
if moves_failed:
print(f"""
- {color(f"{moves_failed} Sieve script directories could not be renamed", RED)}
No reason to panic, but the script tried to rename a Sieve script directory
and that triggered and error. Check further up what went wrong.""")
if dry_run:
print(f"\n{color('No changes were made.', YELLOW)}")
print("Run the script with `--execute` to apply the listed changes.")
sys.exit(EXIT_OK if moves_failed == 0 else EXIT_ERROR)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="""
NixOS Mailserver Migration #5: Sieve script directory migration
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#sieve-script-directory-migration)
"""
)
parser.add_argument(
"sieve_root", type=Path, help="Path to the `mailserver.sieveDirectory`"
)
parser.add_argument(
"--execute", action="store_true", help="Actually perform changes"
)
args = parser.parse_args()
check_user(args.sieve_root)
main(
sieve_root=args.sieve_root,
dry_run=not args.execute,
)
+13 -5
View File
@@ -11,12 +11,13 @@ header = """
"""
template = """
({key})=
`````{{option}} {key}
{description}
{type}
{default}
{example}
{description}
`````
"""
@@ -24,12 +25,15 @@ f = open(sys.argv[1])
options = json.load(f)
groups = [
"mailserver.loginAccounts",
"mailserver.certificate",
"mailserver.accounts",
"mailserver.x509",
"mailserver.storage",
"mailserver.dkim",
"mailserver.srs",
"mailserver.dmarcReporting",
"mailserver.tlsrpt",
"mailserver.fullTextSearch",
"mailserver.quota",
"mailserver.redis",
"mailserver.ldap",
"mailserver.monitoring",
@@ -53,6 +57,8 @@ def render_option_value(option: Mapping[str, Any], key: str) -> str:
if key not in option:
return ""
value = None
if isinstance(option[key], dict) and "_type" in option[key]:
if option[key]["_type"] == "literalExpression":
# multi-line codeblock
@@ -76,7 +82,9 @@ def render_option_value(option: Mapping[str, Any], key: str) -> str:
else:
value = md_literal(text)
return f"- {key}: {value}" # type: ignore
assert value is not None
return f"- {key}: {value}"
def print_option(option):
+87 -86
View File
@@ -77,7 +77,7 @@
];
virusScanning = true;
loginAccounts = {
accounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
@@ -91,7 +91,7 @@
};
environment.etc = {
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
"root/eicar.com.txt".text = "X5O!P%@AP[4PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
};
};
client =
@@ -144,111 +144,112 @@
password user2
'';
};
"root/virus-email".text = ''
From: User2 <user@example2.com>
Content-Type: multipart/mixed;
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
Mime-Version: 1.0 (Mac OS X Mail 11.3 \(3445.6.18\))
Subject: Testy McTest
Message-Id: <94550DD9-1FF1-4ED1-9F09-8812FF2E59AA@example.com>
Date: Sat, 12 May 2018 14:15:44 +0200
To: User1 <user1@example.com>
X-Mailer: Apple Mail (2.3445.6.18)
"root/virus-email".text =
# mail
''
From: User2 <user@example2.com>
Content-Type: multipart/mixed;
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
Mime-Version: 1.0 (Mac OS X Mail 11.3 \(3445.6.18\))
Subject: Testy McTest
Message-Id: <94550DD9-1FF1-4ED1-9F09-8812FF2E59AA@example.com>
Date: Sat, 12 May 2018 14:15:44 +0200
To: User1 <user1@example.com>
X-Mailer: Apple Mail (2.3445.6.18)
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
Content-Transfer-Encoding: 7bit
Content-Type: text/plain;
charset=us-ascii
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
Content-Transfer-Encoding: 7bit
Content-Type: text/plain;
charset=us-ascii
Hello
Hello
I have attached a dangerous virus.
I have attached a dangerous virus.
Mfg.
User2
Mfg.
User2
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
Content-Disposition: attachment;
filename=eicar.com.txt
Content-Type: text/plain;
x-unix-mode=0644;
name="eicar.com.txt"
Content-Transfer-Encoding: 7bit
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
Content-Disposition: attachment;
filename=eicar.com.txt
Content-Type: text/plain;
x-unix-mode=0644;
name="eicar.com.txt"
Content-Transfer-Encoding: 7bit
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607--
'';
"root/safe-email".text = ''
From: User <user@example2.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user@example2.com to user1
Reply-To:
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607--
'';
"root/safe-email".text =
# mail
''
From: User <user@example2.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user@example2.com to user1
Reply-To:
Hello User1,
Hello User1,
how are you doing today?
how are you doing today?
XOXO User1
'';
XOXO User1
'';
};
};
};
testScript = ''
start_all()
testScript =
# python
''
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 ]"
)
server.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
server.wait_for_open_unix_socket("/run/clamav/clamd.ctl")
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 dovecot | grep -i error >&2")
server.fail("journalctl -u dovecot | grep -i warning >&2")
'';
}
+363 -303
View File
@@ -26,7 +26,10 @@
./lib/config.nix
];
environment.systemPackages = with pkgs; [ netcat ];
environment.systemPackages = with pkgs; [
netcat
openssl
];
virtualisation.memorySize = 1024;
@@ -46,10 +49,24 @@
"example2.com"
];
rewriteMessageId = true;
dkimKeyBits = 1535;
dkim = {
defaults.keyLength = 1535;
domains."example2.com".selectors = {
"dkim-rsa" = {
# rsa 1535 bits via defaults
};
"dkim-ed25519" = {
keyType = "ed25519";
keyLength = null;
};
"dkim-file" = {
keyFile = "/run/rspamd/dkim-test.key";
};
};
};
dmarcReporting.enable = true;
loginAccounts = {
accounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
@@ -64,11 +81,11 @@
};
"lowquota@example.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
quota = "1B";
quota = "1K";
};
};
extraVirtualAliases = {
aliases = {
"single-alias@example.com" = "user1@example.com";
"multi-alias@example.com" = [
"user1@example.com"
@@ -81,13 +98,13 @@
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";
fallback = false;
};
};
# by default quota can be exceeded once with this amount (default: 10M)
# this is required to make the quota subtest hard fail on the first attempt.
services.dovecot2.settings.quota_storage_grace = "0";
};
client =
{ nodes, pkgs, ... }:
@@ -104,80 +121,89 @@
echo grep '^Message-ID:.*@mail.example.com>$' "$@" >&2
exec grep '^Message-ID:.*@mail.example.com>$' "$@"
'';
test-imap-spam = pkgs.writeScriptBin "imap-mark-spam" ''
#!${pkgs.python3.interpreter}
import imaplib
test-imap-spam =
pkgs.writeScriptBin "imap-mark-spam"
# python
''
#!${pkgs.python3.interpreter}
import imaplib
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select()
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select()
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.copy(','.join(msg_ids), 'Junk')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.copy(','.join(msg_ids), 'Junk')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.close()
'';
test-imap-ham = pkgs.writeScriptBin "imap-mark-ham" ''
#!${pkgs.python3.interpreter}
import imaplib
imap.close()
'';
test-imap-ham =
pkgs.writeScriptBin "imap-mark-ham"
# python
''
#!${pkgs.python3.interpreter}
import imaplib
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.copy(','.join(msg_ids), 'INBOX')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.copy(','.join(msg_ids), 'INBOX')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.select('INBOX')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.select('INBOX')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.close()
'';
search = pkgs.writeScriptBin "search" ''
#!${pkgs.python3.interpreter}
import imaplib
import sys
imap.close()
'';
search =
pkgs.writeScriptBin "search"
# python
''
#!${pkgs.python3.interpreter}
import imaplib
import sys
[_, mailbox, needle] = sys.argv
[_, mailbox, needle] = sys.argv
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select(mailbox)
status, [response] = imap.search(None, 'BODY', repr(needle))
msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ]
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
status, response = imap.fetch(msg_ids[0], '(RFC822)')
assert status == "OK"
assert needle in repr(response)
imap.close()
'';
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select(mailbox)
status, [response] = imap.search(None, 'BODY', repr(needle))
msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ]
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
status, response = imap.fetch(msg_ids[0], '(RFC822)')
assert status == "OK"
assert needle in repr(response)
imap.close()
'';
in
{
imports = [
@@ -252,277 +278,311 @@
password user1
'';
};
"root/email1".text = ''
Message-ID: <12345qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
"root/email1".text =
# mail
''
Message-ID: <12345qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
Hello User1,
Hello User1,
how are you doing today?
'';
"root/email2".text = ''
Message-ID: <232323abc@host.local.network>
From: User <user@example2.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user@example2.com to user1
Reply-To:
how are you doing today?
'';
"root/email2".text =
# mail
''
Message-ID: <232323abc@host.local.network>
From: User <user@example2.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user@example2.com to user1
Reply-To:
Hello User1,
Hello User1,
how are you doing today?
how are you doing today? I have this exciting text for you, that helps fill
your quota.
XOXO User1
'';
"root/email3".text = ''
Message-ID: <asdfghjkl42@host.local.network>
From: Postmaster <postmaster@example.com>
To: Chuck <chuck@example.com>
Cc:
Bcc:
Subject: This is a test Email from postmaster@example.com to chuck
Reply-To:
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod
tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At
vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,
no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit
amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut
labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata
sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur
sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore
magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo
dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est
Lorem ipsum dolor sit amet.
Hello Chuck,
XOXO User1
'';
"root/email3".text =
# mail
''
Message-ID: <asdfghjkl42@host.local.network>
From: Postmaster <postmaster@example.com>
To: Chuck <chuck@example.com>
Cc:
Bcc:
Subject: This is a test Email from postmaster@example.com to chuck
Reply-To:
I think I may have misconfigured the mail server
XOXO Postmaster
'';
"root/email4".text = ''
Message-ID: <sdfsdf@host.local.network>
From: Single Alias <single-alias@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from single-alias@example.com to user1
Reply-To:
Hello Chuck,
Hello User1,
I think I may have misconfigured the mail server
XOXO Postmaster
'';
"root/email4".text =
# mail
''
Message-ID: <sdfsdf@host.local.network>
From: Single Alias <single-alias@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from single-alias@example.com to user1
Reply-To:
how are you doing today?
Hello User1,
XOXO User1 aka Single Alias
'';
"root/email5".text = ''
Message-ID: <789asdf@host.local.network>
From: User2 <user2@example.com>
To: Multi Alias <multi-alias@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2@example.com to multi-alias
Reply-To:
how are you doing today?
Hello Multi Alias,
XOXO User1 aka Single Alias
'';
"root/email5".text =
# mail
''
Message-ID: <789asdf@host.local.network>
From: User2 <user2@example.com>
To: Multi Alias <multi-alias@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2@example.com to multi-alias
Reply-To:
how are we doing today?
Hello Multi Alias,
XOXO User1
'';
"root/email6".text = ''
Message-ID: <123457qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
how are we doing today?
Hello User1,
XOXO User1
'';
"root/email6".text =
# mail
''
Message-ID: <123457qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
this email contains the needle:
576a4565b70f5a4c1a0925cabdb587a6
'';
"root/email7".text = ''
Message-ID: <1234578qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
Hello User1,
Hello User1,
this email contains the needle:
576a4565b70f5a4c1a0925cabdb587a6
'';
"root/email7".text =
# mail
''
Message-ID: <1234578qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
this email does not contain the needle :(
'';
Hello User1,
this email does not contain the needle :(
'';
};
};
};
testScript = ''
start_all()
testScript =
# python
''
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 ]"
)
server.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
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")
server.succeed("rspamadm dkim_keygen > /run/rspamd/dkim-test.key")
server.succeed("chown rspamd: /run/rspamd/dkim-test.key")
with subtest("imap retrieving mail"):
# fetchmail returns EXIT_CODE 1 when no new mail
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&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("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"):
# fetchmail returns EXIT_CODE 1 when no new mail
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&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("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("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("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("have correct fqdn as sender"):
client.succeed("grep 'Received: from mail.example.com' ~/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("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("have correct fqdn as sender"):
client.succeed("grep 'Received: from mail.example.com' ~/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("dkim has user-specified size"):
server.succeed(
"openssl rsa -in /var/dkim/example2.com.dkim-rsa.key -text -noout | grep 'Private-Key: (1535 bit'"
)
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("dkim signing, 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 's=dkim-rsa' ~/mail/*")
client.succeed("grep 's=dkim-ed25519' ~/mail/*")
client.succeed("grep 's=dkim-file' ~/mail/*")
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("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")
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("domain catch-all"):
client.execute("rm ~/mail/*")
# send email from chuck to non-existent 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("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 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 received the mail that was intended for chuck.
client.fail("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("Test sending from alias address (mailserver.aliases)"):
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("quota"):
client.execute("rm ~/mail/*")
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
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.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("quota"):
client.execute("rm ~/mail/*")
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
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" ]')
server.log(server.succeed("doveadm quota get -u lowquota@example.com"))
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(
"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("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("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" ]')
# 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")
client.succeed("imap-mark-spam >&2")
server.wait_until_succeeds("journalctl -u dovecot | grep -i rspamd-learn-spam.sh >&2")
client.succeed("imap-mark-ham >&2")
server.wait_until_succeeds("journalctl -u dovecot | grep -i rspamd-learn-ham.sh >&2")
with subtest("dmarc reporting"):
server.systemctl("start rspamd-dmarc-reporter.service")
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("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"
)
'';
# 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 | grep 'fts-flatcurve(INBOX): Query ' >&2")
# check that Junk is not indexed
server.fail("journalctl -u dovecot | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
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 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
server.fail(
"journalctl -u dovecot | \
grep -v 'Expunged message reappeared, giving a new UID' | \
grep -v 'Time moved forwards' | \
grep -i warning >&2"
)
'';
}
+105 -8
View File
@@ -38,10 +38,22 @@ let
inherit password;
}
''
mkpasswd -sm bcrypt <<<"$password" > $out
mkpasswd -s <<<"$password" > $out
'';
hashPasswordWithScheme =
password:
pkgs.runCommand "password-${password}-hashed-with-scheme"
{
buildInputs = [ pkgs.dovecot ];
inherit password;
}
''
printf "$password\n$password\n" | doveadm -O pw -s SSHA256 > $out
'';
hashedPasswordFile = hashPassword "my-password";
hashedPasswordFileWithScheme = hashPasswordWithScheme "my-password";
passwordFile = pkgs.writeText "password" "my-password";
in
{
@@ -49,7 +61,7 @@ in
nodes = {
machine =
{ pkgs, ... }:
{ pkgs, lib, ... }:
{
imports = [
./../default.nix
@@ -69,6 +81,17 @@ in
netcat
]);
systemd.tmpfiles.settings."mailserver-test-passwords" = {
"/run/passwords/user3" = {
f = {
argument = "my-password";
mode = "0600";
};
};
};
systemd.services.dovecot.serviceConfig.CacheDirectory = "dovecot";
mailserver = {
enable = true;
fqdn = "mail.example.com";
@@ -78,7 +101,7 @@ in
];
localDnsResolver = false;
loginAccounts = {
accounts = {
"user1@example.com" = {
hashedPasswordFile = hashedPasswordFile;
};
@@ -86,6 +109,13 @@ in
hashedPasswordFile = hashedPasswordFile;
aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ];
};
"user3@example.com" = {
passwordFile = "/run/passwords/user3";
sieveScript = lib.readFile ./lib/redirect.sieve;
};
"user4@example.com" = {
hashedPasswordFile = hashedPasswordFileWithScheme;
};
"send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only";
sendOnly = true;
@@ -97,9 +127,12 @@ in
"user2@example.com" = "user1@example.com";
};
vmailGroupName = "vmail";
vmailUID = 5000;
indexDir = "/var/lib/dovecot/indices";
storage = {
gid = 5000;
group = "vmail";
};
indexDir = "/var/cache/dovecot/fts";
enableImap = false;
};
@@ -110,9 +143,13 @@ in
nodes,
...
}:
# python
''
machine.start()
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("dovecot.service")
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
# Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205
with subtest("mail forwarded can are locally kept"):
@@ -199,8 +236,9 @@ in
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'")
machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.storage.path}/example.com/user1")
machine.succeed("doveadm user -f mail_path user1@example.com | grep ${nodes.machine.mailserver.storage.path}/example.com/user1/mail")
machine.succeed("doveadm user -f mail_index_path user1@example.com | grep ${nodes.machine.mailserver.indexDir}/example.com/user1")
with subtest("mail to send only accounts is rejected"):
machine.wait_for_open_port(25)
@@ -217,6 +255,65 @@ in
"set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'"
)
with subtest("user with plaintext password file can send and receive"):
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user3@example.com",
"--from-addr user3@example.com",
"--to-addr user3@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("user with scheme-prefixed hashedPasswordFile can send and receive"):
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user4@example.com",
"--from-addr user4@example.com",
"--to-addr user4@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("user's static Sieve script is being executed"):
# user3@example.com has a cfg.sieveScript that forwards every
# mail sent from user1@example.com back to user1@example.com
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user1@example.com",
"--from-addr user1@example.com",
"--to-addr user3@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("imap port 143 is closed and imaps is serving SSL"):
machine.wait_for_closed_port(143)
machine.wait_for_open_port(993)
+209 -61
View File
@@ -1,27 +1,37 @@
{
pkgs,
...
}:
let
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
''
mkpasswd -s <<<"$password" > $out
'';
bindPassword = "unsafegibberish";
alicePassword = "testalice";
bobPassword = "testbob";
carolPassword = "testcarol";
malloryPassword = "testmallory";
in
{
name = "ldap";
nodes = {
machine =
{ pkgs, ... }:
{ pkgs, lib, ... }:
{
imports = [
./../default.nix
../default.nix
./lib/config.nix
];
virtualisation.memorySize = 1024;
services.openssh = {
enable = true;
settings.PermitRootLogin = "yes";
};
environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
@@ -53,36 +63,70 @@ in
};
};
};
declarativeContents."dc=example" = ''
dn: dc=example
objectClass: domain
dc: example
declarativeContents."dc=example" =
#ldif
''
dn: dc=example
objectClass: domain
dc: example
dn: cn=mail,dc=example
objectClass: organizationalRole
objectClass: simpleSecurityObject
objectClass: top
cn: mail
userPassword: ${bindPassword}
dn: cn=mail,dc=example
objectClass: organizationalRole
objectClass: simpleSecurityObject
objectClass: top
cn: mail
# unsafegibberish
userPassword: {SSHA}JNr6l3s/RHo1LKRXqFsJg8sXznyRid8L
dn: ou=users,dc=example
objectClass: organizationalUnit
ou: users
dn: 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=alice,ou=users,dc=example
entryUUID: c52f777b-a6e8-4507-80f9-c4de47e8520d
objectClass: inetOrgPerson
uid: alice
sn: Foo
mail: alice@example.com
# testalice
userPassword: {SSHA}gkJq4Dm4jfIKjxviR0WD63wMt0Ti6zMB
dn: cn=bob,ou=users,dc=example
objectClass: inetOrgPerson
cn: bob
sn: Bar
mail: bob@example.com
userPassword: ${bobPassword}
'';
dn: cn=bob,ou=users,dc=example
entryUUID: f3b4e8ea-087f-42cc-95f0-cbfd99386092
objectClass: inetOrgPerson
objectClass: posixAccount
uid: bob
uidNumber: 9999
gidNumber: 9999
sn: Bar
mail: bob@example.com
homeDirectory: /home/bob
# testbob
userPassword: {SSHA}qqUveZGZrDrjYFnREXLDZc//y89RppVN
dn: cn=carol,ou=users,dc=example
entryUUID: 41240499-27e2-4fa2-be4f-4113a77661b1
objectClass: inetOrgPerson
uid: carol
sn: Baz
mail: carol@example.com
# testcarol
userPassword: {SSHA}69HOuP+OPWE+3+tDucFZxzXDC7p4e3ML
dn: cn=frank,ou=users,dc=example
entryUUID: ca16f594-f6b2-418f-87d3-0d02d746461f
objectClass: inetOrgPerson
uid: frank
sn: Moo
mail: frank@example.com
# testfrank
userPassword: {SSHA}xqtMl8/uJ6HEFWDzLYpAE+Wq7FvKrtkm
'';
};
systemd.services.dovecot.serviceConfig = {
CacheDirectory = "dovecot";
StateDirectory = "dovecot";
};
mailserver = {
@@ -90,7 +134,26 @@ in
fqdn = "mail.example.com";
domains = [ "example.com" ];
localDnsResolver = false;
indexDir = "/var/lib/dovecot/indices";
storage.path = "/var/lib/dovecot/vmail";
indexDir = "/var/cache/dovecot/indices";
aliases = {
# Steal frank@example.com from LDAP user frank
"frank@example.com" = "mallory@example.com";
};
accounts = {
# Colliding local account takes precedence over LDAP account with
# same address.
"carol@example.com" = {
hashedPasswordFile = hashPassword carolPassword;
};
# Another account used as a virtual alias target to steal
# frank@example.com from the LDAP user frank
"mallory@example.com" = {
hashedPasswordFile = hashPassword malloryPassword;
};
};
ldap = {
enable = true;
@@ -101,18 +164,52 @@ in
dn = "cn=mail,dc=example";
passwordFile = "/etc/bind-password";
};
searchBase = "ou=users,dc=example";
searchScope = "sub";
base = "ou=users,dc=example";
scope = "sub";
attributes = {
# disable auth bind
password = "userPassword";
};
};
forwards = {
"bob_fw@example.com" = "bob@example.com";
};
};
vmailGroupName = "vmail";
vmailUID = 5000;
specialisation.auth_bind = {
inheritParentConfig = true;
configuration = {
mailserver = {
ldap = {
attributes = {
# enable auth bind
password = lib.mkForce null;
};
};
};
enableImap = false;
services.openldap.settings.children = {
"olcDatabase={1}mdb" = {
attrs = {
olcAccess = [
# disallow access to userPassword
''
to * attrs=userPassword
by anonymous auth
by * none
''
# default policy (same as if we would specify none as all)
''
to *
by * read
''
];
};
};
};
};
};
};
};
@@ -121,6 +218,7 @@ in
nodes,
...
}:
# python
''
import sys
import re
@@ -128,13 +226,21 @@ in
machine.start()
machine.wait_for_unit("multi-user.target")
# if the schema is broken, fail fast. helps during development.
machine.wait_for_unit("openldap.service")
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
# This function retrieves the ldap table file from a postconf
# command.
# A key lookup is achived and the returned value is compared
# A key lookup is achieved and the returned value is compared
# to the expected value.
def test_lookup(postconf_cmdline, key, expected):
conf = machine.succeed(postconf_cmdline).rstrip()
ldap_table_path = re.match('.* =.*ldap:(.*)', conf).group(1)
ldap_table_path_match = re.match('.* =.*ldap:(.*)', conf)
if not ldap_table_path_match:
raise RuntimeError(f"Failed to match LDAP table in '{postconf_cmdline}' response")
ldap_table_path = ldap_table_path_match.group(1)
value = machine.succeed(f"postmap -q {key} ldap:{ldap_table_path}").rstrip()
try:
assert value == expected
@@ -143,19 +249,28 @@ in
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")
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice")
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")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob")
with subtest("Test doveadm lookups"):
machine.succeed("doveadm user -u alice@example.com")
machine.succeed("doveadm user -u bob@example.com")
machine.succeed("doveadm user -u alice")
machine.log(machine.succeed("doveadm user -u bob"))
machine.succeed("doveadm user -f uid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}")
machine.succeed("doveadm user -f gid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}")
machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
machine.succeed("doveadm user -f mail_path bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
machine.succeed("doveadm user -f mail_index_path bob@example.com | grep ${nodes.machine.mailserver.indexDir}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
with subtest("Files containing secrets are only readable by root"):
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([
@@ -163,16 +278,16 @@ in
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice@example.com",
"--smtp-username alice",
"--imap-host localhost",
"--imap-username bob@example.com",
"--imap-username bob",
"--from-addr bob@example.com",
"--to-addr aliceb@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf"
]))
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'")
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice'")
with subtest("Test mail delivery via implicit TLS"):
machine.succeed(" ".join([
@@ -180,9 +295,9 @@ in
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice@example.com",
"--smtp-username alice",
"--imap-host localhost",
"--imap-username bob@example.com",
"--imap-username bob",
"--from-addr alice@example.com",
"--to-addr bob@example.com",
"--src-password-file <(echo '${alicePassword}')",
@@ -196,9 +311,9 @@ in
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice@example.com",
"--smtp-username alice",
"--imap-host localhost",
"--imap-username bob@example.com",
"--imap-username bob",
"--from-addr alice@example.com",
"--to-addr bob_fw@example.com",
"--src-password-file <(echo '${alicePassword}')",
@@ -212,7 +327,7 @@ in
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username bob@example.com",
"--smtp-username bob",
"--imap-host localhost",
"--imap-username alice@example.com",
"--from-addr bob_fw@example.com",
@@ -221,11 +336,44 @@ in
"--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'")
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob'")
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'")
with subtest("Local addresses take priority over those learnt from LDAP"):
# carol@example.com is routed to the local user account
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice", # LDAP user
"--imap-host localhost",
"--imap-username carol@example.com", # Local user
"--from-addr alice@example.com",
"--to-addr carol@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${carolPassword}')",
"--ignore-dkim-spf"
]))
# frank@example.com gets routed to mallory@example.com due to a virtual alias
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice", # LDAP user
"--imap-host localhost",
"--imap-username mallory@example.com", # Local user
"--from-addr alice@example.com",
"--to-addr frank@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${malloryPassword}')",
"--ignore-dkim-spf"
]))
with subtest("LDAP Authentication Binds"):
machine.succeed("/run/booted-system/specialisation/auth_bind/bin/switch-to-configuration test")
machine.wait_for_unit("openldap.service")
machine.succeed("doveadm auth test alice '${alicePassword}'")
'';
}
+11
View File
@@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBizCCATGgAwIBAgIUN4ncJfMVIQSSurMkdE73x4aefTMwCgYIKoZIzj0EAwIw
GzEZMBcGA1UEAwwQdGVzdC5sb2NhbGRvbWFpbjAeFw0yNTEwMTgyMTQ4MTNaFw0z
NTEwMTYyMTQ4MTNaMBsxGTAXBgNVBAMMEHRlc3QubG9jYWxkb21haW4wWTATBgcq
hkjOPQIBBggqhkjOPQMBBwNCAARCJUj4j7eC/7Xso3REUscqHlWPvW9zvl5I6TIy
zEXFsWxM0QxMuNW4oXE56UiCyJklcpk0JfQUGat+kKQqSUJyo1MwUTAdBgNVHQ4E
FgQUW3CnmBf3n/Y30vfj3ERsIQnXu9QwHwYDVR0jBBgwFoAUW3CnmBf3n/Y30vfj
3ERsIQnXu9QwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAhwAi
K4xdr8KxD5xRvvzShheh48i8X7NtBIQ3bd01Jx4CIG/kYTDK5nDZri7UYOMsgz2l
iWss56p2dGWTL7LrBHgM
-----END CERTIFICATE-----
+11
View File
@@ -10,6 +10,17 @@
# Keep testing submission with explicit TLS
mailserver.enableSubmission = true;
# Certificate created for testing purposes from RFC9500 private key
# https://datatracker.ietf.org/doc/rfc9500/
# openssl req -x509 -new -key key.pem \
# -subj "/CN=test.localdomain" \
# -sha256 -days 3650 \
# -out cert.pem
mailserver.x509 = {
certificateFile = "${./cert.pem}";
privateKeyFile = "${./key.pem}";
};
# Enable second CPU core
virtualisation.cores = lib.mkDefault 2;
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIObLW92AqkWunJXowVR2Z5/+yVPBaFHnEedDk5WJxk/BoAoGCCqGSM49
AwEHoUQDQgAEQiVI+I+3gv+17KN0RFLHKh5Vj71vc75eSOkyMsxFxbFsTNEMTLjV
uKFxOelIgsiZJXKZNCX0FBmrfpCkKklCcg==
-----END EC PRIVATE KEY-----
+3
View File
@@ -0,0 +1,3 @@
if address :is "from" "user1@example.com" {
redirect "user1@example.com";
}
+20 -22
View File
@@ -15,7 +15,7 @@ let
inherit password;
}
''
mkpasswd -sm bcrypt <<<"$password" > $out
mkpasswd -s <<<"$password" > $out
'';
password = pkgs.writeText "password" "password";
@@ -35,7 +35,7 @@ let
fqdn = "mail.${domain}";
domains = [ domain ];
localDnsResolver = false;
loginAccounts = {
accounts = {
"user@${domain}" = {
hashedPasswordFile = hashPassword "password";
};
@@ -90,28 +90,26 @@ in
];
};
};
testScript = ''
start_all()
testScript =
# python
''
start_all()
domain1.wait_for_unit("multi-user.target")
domain2.wait_for_unit("multi-user.target")
for domain in [domain1, domain2]:
domain.wait_for_unit("multi-user.target")
domain.wait_for_unit("dovecot.service")
# TODO put this blocking into the systemd units?
domain1.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
domain2.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
for host in [domain1, domain2]:
host.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
# user@domain1.com sends a mail to user@domain2.com via explicit TLS
client.succeed(
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
# user@domain1.com sends a mail to user@domain2.com via explicit TLS
client.succeed(
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
# Send a mail to the address forwarded via implicit TLS and check it is in the recipient mailbox
client.succeed(
"mail-check send-and-read --smtp-port 465 --smtp-ssl --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
'';
# Send a mail to the address forwarded via implicit TLS and check it is in the recipient mailbox
client.succeed(
"mail-check send-and-read --smtp-port 465 --smtp-ssl --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
'';
}