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
This commit is contained in:
+117
-66
@@ -319,7 +319,11 @@ in
|
|||||||
]
|
]
|
||||||
'';
|
'';
|
||||||
description = ''
|
description = ''
|
||||||
URIs where your LDAP server can be reached
|
List of LDAP server URIs. Multiple can be specified.
|
||||||
|
|
||||||
|
Use `ldaps://` for implicit TLS or `ldap://` for a plain connection. See
|
||||||
|
also {option}`mailserver.ldap.startTls` to enable StartTLS on plain
|
||||||
|
connections.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,16 +331,16 @@ in
|
|||||||
type = types.bool;
|
type = types.bool;
|
||||||
default = false;
|
default = false;
|
||||||
description = ''
|
description = ''
|
||||||
Whether to enable StartTLS upon connection to the server.
|
Whether to enable StartTLS on ``ldap://`` connections.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
tlsCAFile = mkOption {
|
caFile = mkOption {
|
||||||
type = types.path;
|
type = types.path;
|
||||||
default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||||
defaultText = literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)";
|
defaultText = lib.literalExpression "\${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||||
description = ''
|
description = ''
|
||||||
Certificate trust anchors used to verify the LDAP server certificate.
|
Bundle of CA certificates used to authenticate the LDAP server certificate.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,88 +349,133 @@ in
|
|||||||
type = types.str;
|
type = types.str;
|
||||||
example = "cn=mail,ou=accounts,dc=example,dc=com";
|
example = "cn=mail,ou=accounts,dc=example,dc=com";
|
||||||
description = ''
|
description = ''
|
||||||
Distinguished name used by the mail server to do lookups
|
DN used to bind against the LDAP server.
|
||||||
against the LDAP servers.
|
|
||||||
|
The server uses this account to lookup and filter accounts.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
passwordFile = mkOption {
|
passwordFile = mkOption {
|
||||||
type = types.str;
|
type = types.pathWith { inStore = false; };
|
||||||
example = "/run/my-secret";
|
example = "/run/my-secret";
|
||||||
description = ''
|
description = ''
|
||||||
A file containing the password required to authenticate against the LDAP servers.
|
File containing the password required to bind against the LDAP server.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
searchBase = mkOption {
|
base = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
example = "ou=people,ou=accounts,dc=example,dc=com";
|
example = "ou=people,ou=accounts,dc=example,dc=com";
|
||||||
description = ''
|
description = ''
|
||||||
Base DN at below which to search for users accounts.
|
Base DN below which user accounts are searched for.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
searchScope = mkOption {
|
scope = mkOption {
|
||||||
type = types.enum [
|
type = types.enum [
|
||||||
"sub"
|
|
||||||
"base"
|
"base"
|
||||||
"one"
|
"one"
|
||||||
|
"sub"
|
||||||
];
|
];
|
||||||
default = "sub";
|
default = "sub";
|
||||||
description = ''
|
description = ''
|
||||||
Search scope below which users accounts are looked for.
|
Search scope relative to the {option}`mailserver.ldap.base`.
|
||||||
|
|
||||||
|
- base: Only the exact Base DN
|
||||||
|
- one: Immediate child entries of the Base DN, but not the Base DN itself.
|
||||||
|
- sub: Base DN and all descendant entries at any depth.
|
||||||
|
|
||||||
|
In practice only `one` or `sub` are suitable for multiple LDAP users.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
dovecot = {
|
attributes = {
|
||||||
userAttrs = mkOption {
|
uuid = mkOption {
|
||||||
type = types.nullOr types.str;
|
type = types.str;
|
||||||
default = null;
|
default = "entryUUID";
|
||||||
|
example = "uuid";
|
||||||
description = ''
|
description = ''
|
||||||
LDAP attributes to be retrieved during userdb lookups.
|
The long-term stable LDAP attribute to reference accounts across
|
||||||
|
username changes. Used to determine a stable Dovecot home and
|
||||||
|
mail directory location.
|
||||||
|
|
||||||
See the users_attrs reference at
|
Typically the `entryUUID` attribute as defined by [RFC4530].
|
||||||
https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-attrs
|
|
||||||
in the Dovecot manual.
|
[RFC4530]: https://www.rfc-editor.org/rfc/rfc4530.html
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
username = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "uid";
|
||||||
|
example = "name";
|
||||||
|
description = ''
|
||||||
|
The LDAP attribute referencing the username used to login with.
|
||||||
|
|
||||||
|
Typically the `uid` attribute which is part of the `inetOrgPerson` schema.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
password = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "userPassword";
|
||||||
|
example = "unix_password";
|
||||||
|
description = ''
|
||||||
|
The LDAP attribute referencing the account password used to login with.
|
||||||
|
|
||||||
|
Typically the `userPassword` attribute which is part of the `inetOrgPerson` schema.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
mail = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "mail";
|
||||||
|
example = "maildrop";
|
||||||
|
description = ''
|
||||||
|
The attribute name used for looking up accounts by mail address.
|
||||||
|
|
||||||
|
Typically this can be the `mail` attribute from the `inetOrgPerson`
|
||||||
|
schema, or the `maildrop` attribute from the unofficial Postfix
|
||||||
|
schema.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
dovecot = {
|
||||||
userFilter = mkOption {
|
userFilter = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "mail=%{user}";
|
default = with cfg.ldap.attributes; "(|(${mail}=%{user})(${username}=%{user}))";
|
||||||
example = "(&(objectClass=inetOrgPerson)(mail=%{user}))";
|
defaultText = literalExpression ''
|
||||||
description = ''
|
with config.mailserver.ldap.attributes; "(|(''${mail}=%{user})(''${username}=%{user}))";
|
||||||
Filter for user lookups in Dovecot.
|
|
||||||
|
|
||||||
See the user_filter reference at
|
|
||||||
https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-filter
|
|
||||||
in the Dovecot manual.
|
|
||||||
'';
|
'';
|
||||||
};
|
example = "(|(mail=%{user})(uid=%{user}))";
|
||||||
|
|
||||||
passAttrs = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "userPassword=password";
|
|
||||||
description = ''
|
description = ''
|
||||||
LDAP attributes to be retrieved during passdb lookups.
|
LDAP filter used for LMTP delivery from Postfix and post-login
|
||||||
|
information construction, like the home directory.
|
||||||
|
|
||||||
See the pass_attrs reference at
|
See the [user_filter] reference at in the Dovecot manual.
|
||||||
https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-attrs
|
|
||||||
in the Dovecot manual.
|
[user_filter]: https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-filter
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
passFilter = mkOption {
|
passFilter = mkOption {
|
||||||
type = types.nullOr types.str;
|
type = types.nullOr types.str;
|
||||||
default = "mail=%{user}";
|
default = with cfg.ldap.attributes; "${username}=%{user}";
|
||||||
example = "(&(objectClass=inetOrgPerson)(mail=%{user}))";
|
defaultText = lib.literalExpression ''
|
||||||
|
with config.mailserver.ldap.attributes; "''${username}=%{user}";
|
||||||
|
'';
|
||||||
|
example =
|
||||||
|
with cfg.ldap.attributes;
|
||||||
|
"(&(memberOf=cn=mail_users,ou=groups,dc=example,dc=com)(${username}=%{user}))";
|
||||||
description = ''
|
description = ''
|
||||||
Filter for password lookups in Dovecot.
|
LDAP filter used to restrict which users are eligible to
|
||||||
|
authenticate against Dovecot.
|
||||||
|
|
||||||
See the pass_filter reference for
|
See the [pass_filter] reference in the Dovecot manual.
|
||||||
https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-filter
|
|
||||||
in the Dovecot manual.
|
[pass_filter]: https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-filter
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -434,29 +483,14 @@ in
|
|||||||
postfix = {
|
postfix = {
|
||||||
filter = mkOption {
|
filter = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "mail=%s";
|
default = with cfg.ldap.attributes; "${mail}=%s";
|
||||||
example = "(&(objectClass=inetOrgPerson)(mail=%s))";
|
defaultText = lib.literalExpression ''
|
||||||
description = ''
|
with config.mailserver.ldap.attributes; "''${mail}=%s";
|
||||||
LDAP filter used to search for an account by mail, where
|
|
||||||
`%s` is a substitute for the address in
|
|
||||||
question.
|
|
||||||
'';
|
'';
|
||||||
};
|
example = "(mail=%s)";
|
||||||
|
|
||||||
uidAttribute = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "mail";
|
|
||||||
example = "uid";
|
|
||||||
description = ''
|
description = ''
|
||||||
The LDAP attribute referencing the account name for a user.
|
LDAP filter used to search for an account by mail, where `%s` is a
|
||||||
'';
|
substitute for the address in question.
|
||||||
};
|
|
||||||
|
|
||||||
mailAttribute = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "mail";
|
|
||||||
description = ''
|
|
||||||
The LDAP attribute holding mail addresses for a user.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1630,5 +1664,22 @@ in
|
|||||||
[ "mailserver" "dkimKeyBits" ]
|
[ "mailserver" "dkimKeyBits" ]
|
||||||
[ "mailserver" "dkim" "defaults" "keyLength" ]
|
[ "mailserver" "dkim" "defaults" "keyLength" ]
|
||||||
)
|
)
|
||||||
|
(mkRemovedOptionModule [ "mailserver" "ldap" "dovecot" "userAttrs" ] ''
|
||||||
|
The user_attrs field is now used internally to map the home and mail directories.
|
||||||
|
'')
|
||||||
|
(mkRemovedOptionModule [ "mailserver" "ldap" "dovecot" "passAttrs" ] ''
|
||||||
|
The pass_attrs field is now used internally. You can customize the `mailserver.ldap.attributes.password` field instead.
|
||||||
|
'')
|
||||||
|
(mkRenamedOptionModule [ "mailserver" "ldap" "tlsCAFile" ] [ "mailserver" "ldap" "caFile" ])
|
||||||
|
(mkRenamedOptionModule [ "mailserver" "ldap" "searchBase" ] [ "mailserver" "ldap" "base" ])
|
||||||
|
(mkRenamedOptionModule [ "mailserver" "ldap" "searchScope" ] [ "mailserver" "ldap" "scope" ])
|
||||||
|
(mkRenamedOptionModule
|
||||||
|
[ "mailserver" "ldap" "postfix" "uidAttribute" ]
|
||||||
|
[ "mailserver" "ldap" "attributes" "username" ]
|
||||||
|
)
|
||||||
|
(mkRenamedOptionModule
|
||||||
|
[ "mailserver" "ldap" "postfix" "mailAttribute" ]
|
||||||
|
[ "mailserver" "ldap" "attributes" "mail" ]
|
||||||
|
)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -21,13 +21,18 @@ Welcome to NixOS Mailserver's documentation!
|
|||||||
options
|
options
|
||||||
migrations
|
migrations
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
:caption: Account backends
|
||||||
|
|
||||||
|
ldap
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
:caption: Features
|
:caption: Features
|
||||||
|
|
||||||
dkim
|
dkim
|
||||||
fts
|
fts
|
||||||
ldap
|
|
||||||
srs
|
srs
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
mailserver = {
|
||||||
|
ldap = {
|
||||||
|
attributes = {
|
||||||
|
uuid = "entryUUID";
|
||||||
|
username = "uid";
|
||||||
|
password = "userPassword";
|
||||||
|
mail = "mail";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
+78
-10
@@ -1,14 +1,82 @@
|
|||||||
LDAP Support
|
LDAP
|
||||||
============
|
====
|
||||||
|
|
||||||
It is possible to manage mail user accounts with LDAP rather than with
|
LDAP (Lightweight Directory Access Protocol) is a protocol for accessing and
|
||||||
the option `loginAccounts <options.html#mailserver-loginaccounts>`_.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
.. note::
|
Requirements
|
||||||
The LDAP support can not be enabled if some accounts are also defined with ``mailserver.loginAccounts``.
|
~~~~~~~~~~~~
|
||||||
|
|
||||||
|
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
|
||||||
|
~~~~~~~~~~~
|
||||||
|
|
||||||
|
We have various assertions in place, that prevent using LDAP together with
|
||||||
|
other features. Most of them are not technical limitations per se, but instead
|
||||||
|
lack configuration or validation.
|
||||||
|
|
||||||
|
- Local users (:option:`mailserver.loginAccounts`) and aliases
|
||||||
|
(:option:`mailserver.extraVirtualAliases`) are not currently allowed with
|
||||||
|
:option:`mailserver.ldap.enable` enabled
|
||||||
|
- Aliases based on LDAP attributes are currently not implemented
|
||||||
|
- Quotas based on LDAP attributes are currently not implemented
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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/master/tests/ldap.nix
|
||||||
|
|||||||
+101
-4
@@ -5,10 +5,107 @@ With mail server configuration best practices changing over time we might need
|
|||||||
to make changes that require you to complete manual migration steps before you
|
to make changes that require you to complete manual migration steps before you
|
||||||
can deploy a new version of NixOS mailserver.
|
can deploy a new version of NixOS mailserver.
|
||||||
|
|
||||||
The initial `mailserver.stateVersion` value should be copied from the setup
|
The initial :option:`mailserver.stateVersion` value should be copied from the
|
||||||
guide that you used to initially set up your mail server. If in doubt you can
|
setup guide that you used to initially set up your mail server. If in doubt you
|
||||||
always initialize it at `1` and walk through all assertions, that might apply
|
can always initialize it at ``1`` and walk through all assertions, that might
|
||||||
to your setup.
|
apply to your setup.
|
||||||
|
|
||||||
|
NixOS 26.05
|
||||||
|
-----------
|
||||||
|
|
||||||
|
#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 script to your mailserver and make it executable:
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
cd /tmp
|
||||||
|
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/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.mailDirectory`, 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.mailDirectory`.
|
||||||
|
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.mailDirectory`),
|
||||||
|
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 neceessary 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 yours LDAP provider isn't listed you can determine the correct
|
||||||
|
attribute by quering a user entry with ``ldapsearch``. Finally, configure
|
||||||
|
:option:`mailserver.ldap.attributes.uuid` accordingly.
|
||||||
|
|
||||||
|
Add ``--ldap-starttls`` if you use the the `ldap://` URI scheme and require
|
||||||
|
explicit TLS.
|
||||||
|
|
||||||
|
.. _[1]: https://docs.goauthentik.io/add-secure-apps/providers/ldap#users
|
||||||
|
.. _[2]: https://kanidm.github.io/kanidm/stable/integrations/ldap.html#data-mapping
|
||||||
|
.. _RFC4530: https://www.rfc-editor.org/rfc/rfc4530.html
|
||||||
|
|
||||||
|
5. Review the script output.
|
||||||
|
|
||||||
|
It's primary job is to determine the UUID for an LDAP account, so that it
|
||||||
|
can rename the Dovecot home directory from mail address to UUID within the
|
||||||
|
same directory.
|
||||||
|
|
||||||
|
The script can highlight various inconsistencies and problems, that should
|
||||||
|
be reviewed and acted upon.
|
||||||
|
|
||||||
|
If in doubt, join our community chat for help before applying any changes.
|
||||||
|
|
||||||
|
6. Rerun the command with ``--execute`` or run the proposed commands manually.
|
||||||
|
|
||||||
|
7. Update the ``mailserver.stateVersion`` to ``4``.
|
||||||
|
|
||||||
NixOS 25.11
|
NixOS 25.11
|
||||||
-----------
|
-----------
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ NixOS 26.05
|
|||||||
established by `agenix`_/`sops-nix`_ that instead rely on encryption. This
|
established by `agenix`_/`sops-nix`_ that instead rely on encryption. This
|
||||||
option prevents files from leaking in to the Nix store.
|
option prevents files from leaking in to the Nix store.
|
||||||
See :option:`mailserver.loginAccounts.<name>.passwordFile`.
|
See :option:`mailserver.loginAccounts.<name>.passwordFile`.
|
||||||
|
- LDAP setups require a migration of Dovecot home directories to
|
||||||
|
`UUID based home directories`_. The exact UUID attribute can be customized
|
||||||
|
through :option:`mailserver.ldap.attributes.uuid`.
|
||||||
|
- The default login username for LDAP users has changed from the ``mail`` to
|
||||||
|
the ``uid`` attribute. 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 following integrations are deprecated and will be removed before the next
|
- The following integrations are deprecated and will be removed before the next
|
||||||
release:
|
release:
|
||||||
|
|
||||||
@@ -33,6 +41,7 @@ NixOS 26.05
|
|||||||
.. _DKIM key management: dkim.html
|
.. _DKIM key management: dkim.html
|
||||||
.. _agenix: https://github.com/ryantm/agenix
|
.. _agenix: https://github.com/ryantm/agenix
|
||||||
.. _sops-nix: https://github.com/Mic92/sops-nix
|
.. _sops-nix: https://github.com/Mic92/sops-nix
|
||||||
|
.. _UUID based home directories: migrations.html#dovecot-ldap-uuid-based-home-directories
|
||||||
|
|
||||||
NixOS 25.11
|
NixOS 25.11
|
||||||
-----------
|
-----------
|
||||||
|
|||||||
@@ -134,5 +134,15 @@ in
|
|||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
++ lib.optionals (config.mailserver.ldap.enable) [
|
||||||
|
{
|
||||||
|
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 4;
|
||||||
|
message = ''
|
||||||
|
NixOS Mailserver requires migrating LDAP home directories to UUID scheme
|
||||||
|
|
||||||
|
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-uuid-based-home-directory for required migration steps.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-16
@@ -61,6 +61,7 @@ let
|
|||||||
|
|
||||||
postfixCfg = config.services.postfix;
|
postfixCfg = config.services.postfix;
|
||||||
|
|
||||||
|
ldapUuidAttribute = cfg.ldap.attributes.uuid;
|
||||||
ldapConfig = pkgs.writeTextFile {
|
ldapConfig = pkgs.writeTextFile {
|
||||||
name = "dovecot-ldap.conf.ext.template";
|
name = "dovecot-ldap.conf.ext.template";
|
||||||
text = ''
|
text = ''
|
||||||
@@ -70,19 +71,20 @@ let
|
|||||||
tls = yes
|
tls = yes
|
||||||
''}
|
''}
|
||||||
tls_require_cert = hard
|
tls_require_cert = hard
|
||||||
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
|
tls_ca_cert_file = ${cfg.ldap.caFile}
|
||||||
dn = ${cfg.ldap.bind.dn}
|
dn = ${cfg.ldap.bind.dn}
|
||||||
sasl_bind = no
|
sasl_bind = no
|
||||||
auth_bind = yes
|
auth_bind = yes
|
||||||
base = ${cfg.ldap.searchBase}
|
base = ${cfg.ldap.base}
|
||||||
scope = ${mkLdapSearchScope cfg.ldap.searchScope}
|
scope = ${mkLdapSearchScope cfg.ldap.scope}
|
||||||
${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) ''
|
user_attrs = \
|
||||||
user_attrs = ${cfg.ldap.dovecot.userAttrs}
|
${ldapUuidAttribute}=${ldapUuidAttribute}, \
|
||||||
''}
|
=home=/var/vmail/ldap/%{ldap:${ldapUuidAttribute}}, \
|
||||||
|
=mail=maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}${
|
||||||
|
lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/ldap/%{ldap:${ldapUuidAttribute}}"
|
||||||
|
}
|
||||||
user_filter = ${cfg.ldap.dovecot.userFilter}
|
user_filter = ${cfg.ldap.dovecot.userFilter}
|
||||||
${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") ''
|
pass_attrs = ${cfg.ldap.attributes.password}=password
|
||||||
pass_attrs = ${cfg.ldap.dovecot.passAttrs}
|
|
||||||
''}
|
|
||||||
pass_filter = ${cfg.ldap.dovecot.passFilter}
|
pass_filter = ${cfg.ldap.dovecot.passFilter}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -443,14 +445,9 @@ in
|
|||||||
userdb {
|
userdb {
|
||||||
driver = ldap
|
driver = ldap
|
||||||
args = ${ldapConfFile}
|
args = ${ldapConfFile}
|
||||||
default_fields = \
|
override_fields = \
|
||||||
home=${cfg.mailDirectory}/ldap/%{user} \
|
|
||||||
uid=${toString cfg.vmailUID} \
|
uid=${toString cfg.vmailUID} \
|
||||||
gid=${toString cfg.vmailUID} \
|
gid=${toString cfg.vmailUID}
|
||||||
mail=maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}${
|
|
||||||
lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/ldap/%{user}"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
''}
|
''}
|
||||||
|
|
||||||
|
|||||||
@@ -209,20 +209,21 @@ let
|
|||||||
server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
|
server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
|
||||||
start_tls = ${if cfg.ldap.startTls then "yes" else "no"}
|
start_tls = ${if cfg.ldap.startTls then "yes" else "no"}
|
||||||
version = 3
|
version = 3
|
||||||
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
|
tls_ca_cert_file = ${cfg.ldap.caFile}
|
||||||
tls_require_cert = yes
|
tls_require_cert = yes
|
||||||
|
|
||||||
search_base = ${cfg.ldap.searchBase}
|
search_base = ${cfg.ldap.base}
|
||||||
scope = ${cfg.ldap.searchScope}
|
scope = ${cfg.ldap.scope}
|
||||||
|
|
||||||
bind = yes
|
bind = yes
|
||||||
bind_dn = ${cfg.ldap.bind.dn}
|
bind_dn = ${cfg.ldap.bind.dn}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# Enforce a mapping between SMTP user and envelope sender address
|
||||||
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
|
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
|
||||||
${commonLdapConfig}
|
${commonLdapConfig}
|
||||||
query_filter = ${cfg.ldap.postfix.filter}
|
query_filter = ${cfg.ldap.postfix.filter}
|
||||||
result_attribute = ${cfg.ldap.postfix.mailAttribute}
|
result_attribute = ${cfg.ldap.attributes.username}
|
||||||
'';
|
'';
|
||||||
ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf";
|
ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf";
|
||||||
appendPwdInSenderLoginMap = appendLdapBindPwd {
|
appendPwdInSenderLoginMap = appendLdapBindPwd {
|
||||||
@@ -233,10 +234,11 @@ let
|
|||||||
destination = ldapSenderLoginMapFile;
|
destination = ldapSenderLoginMapFile;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Check whether a recipient address exists, before accepting mail for it
|
||||||
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
|
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
|
||||||
${commonLdapConfig}
|
${commonLdapConfig}
|
||||||
query_filter = ${cfg.ldap.postfix.filter}
|
query_filter = ${cfg.ldap.postfix.filter}
|
||||||
result_attribute = ${cfg.ldap.postfix.uidAttribute}
|
result_attribute = ${cfg.ldap.attributes.username}
|
||||||
'';
|
'';
|
||||||
ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf";
|
ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf";
|
||||||
appendPwdInVirtualMailboxMap = appendLdapBindPwd {
|
appendPwdInVirtualMailboxMap = appendLdapBindPwd {
|
||||||
|
|||||||
@@ -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 succesfully.", 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 directores 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.exampe.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 adress.",
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
+36
-34
@@ -11,17 +11,10 @@ in
|
|||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
./../default.nix
|
../default.nix
|
||||||
./lib/config.nix
|
./lib/config.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
virtualisation.memorySize = 1024;
|
|
||||||
|
|
||||||
services.openssh = {
|
|
||||||
enable = true;
|
|
||||||
settings.PermitRootLogin = "yes";
|
|
||||||
};
|
|
||||||
|
|
||||||
environment.systemPackages = [
|
environment.systemPackages = [
|
||||||
(pkgs.writeScriptBin "mail-check" ''
|
(pkgs.writeScriptBin "mail-check" ''
|
||||||
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
|
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
|
||||||
@@ -72,17 +65,23 @@ in
|
|||||||
ou: users
|
ou: users
|
||||||
|
|
||||||
dn: cn=alice,ou=users,dc=example
|
dn: cn=alice,ou=users,dc=example
|
||||||
|
entryUUID: c52f777b-a6e8-4507-80f9-c4de47e8520d
|
||||||
objectClass: inetOrgPerson
|
objectClass: inetOrgPerson
|
||||||
cn: alice
|
uid: alice
|
||||||
sn: Foo
|
sn: Foo
|
||||||
mail: alice@example.com
|
mail: alice@example.com
|
||||||
userPassword: ${alicePassword}
|
userPassword: ${alicePassword}
|
||||||
|
|
||||||
dn: cn=bob,ou=users,dc=example
|
dn: cn=bob,ou=users,dc=example
|
||||||
|
entryUUID: f3b4e8ea-087f-42cc-95f0-cbfd99386092
|
||||||
objectClass: inetOrgPerson
|
objectClass: inetOrgPerson
|
||||||
cn: bob
|
objectClass: posixAccount
|
||||||
|
uid: bob
|
||||||
|
uidNumber: 9999
|
||||||
|
gidNumber: 9999
|
||||||
sn: Bar
|
sn: Bar
|
||||||
mail: bob@example.com
|
mail: bob@example.com
|
||||||
|
homeDirectory: /home/bob
|
||||||
userPassword: ${bobPassword}
|
userPassword: ${bobPassword}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -103,18 +102,13 @@ in
|
|||||||
dn = "cn=mail,dc=example";
|
dn = "cn=mail,dc=example";
|
||||||
passwordFile = "/etc/bind-password";
|
passwordFile = "/etc/bind-password";
|
||||||
};
|
};
|
||||||
searchBase = "ou=users,dc=example";
|
base = "ou=users,dc=example";
|
||||||
searchScope = "sub";
|
scope = "sub";
|
||||||
};
|
};
|
||||||
|
|
||||||
forwards = {
|
forwards = {
|
||||||
"bob_fw@example.com" = "bob@example.com";
|
"bob_fw@example.com" = "bob@example.com";
|
||||||
};
|
};
|
||||||
|
|
||||||
vmailGroupName = "vmail";
|
|
||||||
vmailUID = 5000;
|
|
||||||
|
|
||||||
enableImap = false;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -131,6 +125,9 @@ in
|
|||||||
machine.start()
|
machine.start()
|
||||||
machine.wait_for_unit("multi-user.target")
|
machine.wait_for_unit("multi-user.target")
|
||||||
|
|
||||||
|
# if the schema is broken, fail fast. helps during development.
|
||||||
|
machine.wait_for_unit("openldap.service")
|
||||||
|
|
||||||
# TODO put this blocking into the systemd units?
|
# TODO put this blocking into the systemd units?
|
||||||
machine.wait_until_succeeds(
|
machine.wait_until_succeeds(
|
||||||
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
||||||
@@ -151,16 +148,25 @@ in
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
with subtest("Test postmap lookups"):
|
with subtest("Test postmap lookups"):
|
||||||
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice@example.com")
|
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice")
|
||||||
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice@example.com")
|
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice")
|
||||||
|
|
||||||
test_lookup("postconf virtual_mailbox_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@example.com")
|
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob")
|
||||||
|
|
||||||
with subtest("Test doveadm lookups"):
|
with subtest("Test doveadm lookups"):
|
||||||
machine.succeed("doveadm user -u alice@example.com")
|
machine.succeed("doveadm user -u alice@example.com")
|
||||||
machine.succeed("doveadm user -u bob@example.com")
|
machine.succeed("doveadm user -u bob@example.com")
|
||||||
|
|
||||||
|
machine.succeed("doveadm user -u alice")
|
||||||
|
machine.log(machine.succeed("doveadm user -u bob"))
|
||||||
|
|
||||||
|
machine.succeed("doveadm user -f uid bob@example.com | grep ${toString nodes.machine.mailserver.vmailUID}")
|
||||||
|
machine.succeed("doveadm user -f gid bob@example.com | grep ${toString nodes.machine.mailserver.vmailUID}")
|
||||||
|
|
||||||
|
machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.mailDirectory}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
|
||||||
|
machine.succeed("doveadm user -f mail bob@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092'")
|
||||||
|
|
||||||
with subtest("Files containing secrets are only readable by root"):
|
with subtest("Files containing secrets are only readable by root"):
|
||||||
machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'")
|
machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'")
|
||||||
machine.succeed("ls -l /run/dovecot2/dovecot-ldap.conf.ext | grep -e '-rw------- 1 root root'")
|
machine.succeed("ls -l /run/dovecot2/dovecot-ldap.conf.ext | grep -e '-rw------- 1 root root'")
|
||||||
@@ -171,16 +177,16 @@ in
|
|||||||
"--smtp-port 587",
|
"--smtp-port 587",
|
||||||
"--smtp-starttls",
|
"--smtp-starttls",
|
||||||
"--smtp-host localhost",
|
"--smtp-host localhost",
|
||||||
"--smtp-username alice@example.com",
|
"--smtp-username alice",
|
||||||
"--imap-host localhost",
|
"--imap-host localhost",
|
||||||
"--imap-username bob@example.com",
|
"--imap-username bob",
|
||||||
"--from-addr bob@example.com",
|
"--from-addr bob@example.com",
|
||||||
"--to-addr aliceb@example.com",
|
"--to-addr aliceb@example.com",
|
||||||
"--src-password-file <(echo '${alicePassword}')",
|
"--src-password-file <(echo '${alicePassword}')",
|
||||||
"--dst-password-file <(echo '${bobPassword}')",
|
"--dst-password-file <(echo '${bobPassword}')",
|
||||||
"--ignore-dkim-spf"
|
"--ignore-dkim-spf"
|
||||||
]))
|
]))
|
||||||
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'")
|
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice'")
|
||||||
|
|
||||||
with subtest("Test mail delivery via implicit TLS"):
|
with subtest("Test mail delivery via implicit TLS"):
|
||||||
machine.succeed(" ".join([
|
machine.succeed(" ".join([
|
||||||
@@ -188,9 +194,9 @@ in
|
|||||||
"--smtp-port 465",
|
"--smtp-port 465",
|
||||||
"--smtp-ssl",
|
"--smtp-ssl",
|
||||||
"--smtp-host localhost",
|
"--smtp-host localhost",
|
||||||
"--smtp-username alice@example.com",
|
"--smtp-username alice",
|
||||||
"--imap-host localhost",
|
"--imap-host localhost",
|
||||||
"--imap-username bob@example.com",
|
"--imap-username bob",
|
||||||
"--from-addr alice@example.com",
|
"--from-addr alice@example.com",
|
||||||
"--to-addr bob@example.com",
|
"--to-addr bob@example.com",
|
||||||
"--src-password-file <(echo '${alicePassword}')",
|
"--src-password-file <(echo '${alicePassword}')",
|
||||||
@@ -204,9 +210,9 @@ in
|
|||||||
"--smtp-port 587",
|
"--smtp-port 587",
|
||||||
"--smtp-starttls",
|
"--smtp-starttls",
|
||||||
"--smtp-host localhost",
|
"--smtp-host localhost",
|
||||||
"--smtp-username alice@example.com",
|
"--smtp-username alice",
|
||||||
"--imap-host localhost",
|
"--imap-host localhost",
|
||||||
"--imap-username bob@example.com",
|
"--imap-username bob",
|
||||||
"--from-addr alice@example.com",
|
"--from-addr alice@example.com",
|
||||||
"--to-addr bob_fw@example.com",
|
"--to-addr bob_fw@example.com",
|
||||||
"--src-password-file <(echo '${alicePassword}')",
|
"--src-password-file <(echo '${alicePassword}')",
|
||||||
@@ -220,7 +226,7 @@ in
|
|||||||
"--smtp-port 465",
|
"--smtp-port 465",
|
||||||
"--smtp-ssl",
|
"--smtp-ssl",
|
||||||
"--smtp-host localhost",
|
"--smtp-host localhost",
|
||||||
"--smtp-username bob@example.com",
|
"--smtp-username bob",
|
||||||
"--imap-host localhost",
|
"--imap-host localhost",
|
||||||
"--imap-username alice@example.com",
|
"--imap-username alice@example.com",
|
||||||
"--from-addr bob_fw@example.com",
|
"--from-addr bob_fw@example.com",
|
||||||
@@ -229,11 +235,7 @@ in
|
|||||||
"--dst-password-file <(echo '${alicePassword}')",
|
"--dst-password-file <(echo '${alicePassword}')",
|
||||||
"--ignore-dkim-spf"
|
"--ignore-dkim-spf"
|
||||||
]))
|
]))
|
||||||
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob@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'")
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user