From fa0d5c969426a81f7925037ad8966ebcdef0f012 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sun, 8 Mar 2026 03:58:05 +0100 Subject: [PATCH 01/10] 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. --- tests/ldap.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ldap.nix b/tests/ldap.nix index d8f5af3..b875715 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -131,6 +131,9 @@ 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") + # TODO put this blocking into the systemd units? machine.wait_until_succeeds( "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" From 091eda1ed2ec0341303d1f287e92b3b3ebe7c048 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 12 Mar 2026 03:18:48 +0100 Subject: [PATCH 02/10] 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. --- default.nix | 24 ++++++++++++++++-------- mail-server/dovecot.nix | 17 ++++++++--------- tests/ldap.nix | 17 +++++++++++++---- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/default.nix b/default.nix index ad000ff..3472d57 100644 --- a/default.nix +++ b/default.nix @@ -379,19 +379,24 @@ in ''; }; - dovecot = { - userAttrs = mkOption { - type = types.nullOr types.str; - default = null; + attributes = { + uuid = mkOption { + type = types.str; + default = "entryUUID"; + example = "uuid"; 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 - https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-attrs - in the Dovecot manual. + Typically the `entryUUID` attribute as defined by [RFC4530]. + + [RFC4530]: https://www.rfc-editor.org/rfc/rfc4530.html ''; }; + }; + dovecot = { userFilter = mkOption { type = types.str; default = "mail=%{user}"; @@ -1630,5 +1635,8 @@ in [ "mailserver" "dkimKeyBits" ] [ "mailserver" "dkim" "defaults" "keyLength" ] ) + (mkRemovedOptionModule [ "mailserver" "ldap" "dovecot" "userAttrs" ] '' + The user_attrs field is now used internally to map the home and mail directories. + '') ]; } diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 7831b0f..fda742a 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -61,6 +61,7 @@ let postfixCfg = config.services.postfix; + ldapUuidAttribute = cfg.ldap.attributes.uuid; ldapConfig = pkgs.writeTextFile { name = "dovecot-ldap.conf.ext.template"; text = '' @@ -76,9 +77,12 @@ let 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_attrs = \ + ${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} ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' pass_attrs = ${cfg.ldap.dovecot.passAttrs} @@ -444,13 +448,8 @@ in 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}" - } - + gid=${toString cfg.vmailUID} } ''} diff --git a/tests/ldap.nix b/tests/ldap.nix index b875715..d2e89fa 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -72,6 +72,7 @@ in ou: users dn: cn=alice,ou=users,dc=example + entryUUID: c52f777b-a6e8-4507-80f9-c4de47e8520d objectClass: inetOrgPerson cn: alice sn: Foo @@ -79,10 +80,16 @@ in userPassword: ${alicePassword} dn: cn=bob,ou=users,dc=example + entryUUID: f3b4e8ea-087f-42cc-95f0-cbfd99386092 objectClass: inetOrgPerson + objectClass: posixAccount cn: bob + uid: bob + uidNumber: 9999 + gidNumber: 9999 sn: Bar mail: bob@example.com + homeDirectory: /home/bob userPassword: ${bobPassword} ''; }; @@ -164,6 +171,12 @@ in machine.succeed("doveadm user -u alice@example.com") machine.succeed("doveadm user -u bob@example.com") + 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"): 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'") @@ -234,9 +247,5 @@ in ])) machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob@example.com'") - with subtest("Check dovecot mail and index locations"): - # If these paths change we need a migration - machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.mailDirectory}/ldap/bob@example.com") - machine.succeed("doveadm user -f mail bob@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/ldap/bob@example.com'") ''; } From af480dba878e2203d1553724a55cedd45f86b2d0 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sun, 15 Mar 2026 18:44:31 +0100 Subject: [PATCH 03/10] 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. --- default.nix | 26 ++++++++++++++------------ mail-server/dovecot.nix | 4 +--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/default.nix b/default.nix index 3472d57..ad3164e 100644 --- a/default.nix +++ b/default.nix @@ -394,6 +394,17 @@ in [RFC4530]: https://www.rfc-editor.org/rfc/rfc4530.html ''; }; + + 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. + ''; + }; }; dovecot = { @@ -410,18 +421,6 @@ in ''; }; - passAttrs = mkOption { - type = types.str; - default = "userPassword=password"; - description = '' - LDAP attributes to be retrieved during passdb lookups. - - See the pass_attrs reference at - https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-attrs - in the Dovecot manual. - ''; - }; - passFilter = mkOption { type = types.nullOr types.str; default = "mail=%{user}"; @@ -1638,5 +1637,8 @@ in (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. + '') ]; } diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index fda742a..307f0f4 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -84,9 +84,7 @@ let lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/ldap/%{ldap:${ldapUuidAttribute}}" } user_filter = ${cfg.ldap.dovecot.userFilter} - ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' - pass_attrs = ${cfg.ldap.dovecot.passAttrs} - ''} + pass_attrs = ${cfg.ldap.attributes.password}=password pass_filter = ${cfg.ldap.dovecot.passFilter} ''; }; From 609fd8093610923ce5c48eb742c47aa9b72770ea Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 12 Mar 2026 03:20:13 +0100 Subject: [PATCH 04/10] 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. --- mail-server/dovecot.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 307f0f4..148c97d 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -445,7 +445,7 @@ in userdb { driver = ldap args = ${ldapConfFile} - default_fields = \ + override_fields = \ uid=${toString cfg.vmailUID} \ gid=${toString cfg.vmailUID} } From a87d01ea79c50789d90f1f94d40357fec118dfe7 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 12 Mar 2026 03:23:51 +0100 Subject: [PATCH 05/10] 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. --- default.nix | 119 +++++++++++++++++++++++++--------------- mail-server/dovecot.nix | 6 +- mail-server/postfix.nix | 10 ++-- tests/ldap.nix | 4 +- 4 files changed, 86 insertions(+), 53 deletions(-) diff --git a/default.nix b/default.nix index ad3164e..d8c39d0 100644 --- a/default.nix +++ b/default.nix @@ -319,7 +319,11 @@ in ] ''; 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; default = false; description = '' - Whether to enable StartTLS upon connection to the server. + Whether to enable StartTLS on ``ldap://`` connections. ''; }; - tlsCAFile = mkOption { + caFile = mkOption { type = types.path; 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 = '' - Certificate trust anchors used to verify the LDAP server certificate. + Bundle of CA certificates used to authenticate the LDAP server certificate. ''; }; @@ -345,37 +349,44 @@ in type = types.str; example = "cn=mail,ou=accounts,dc=example,dc=com"; description = '' - Distinguished name used by the mail server to do lookups - against the LDAP servers. + DN used to bind against the LDAP server. + + The server uses this account to lookup and filter accounts. ''; }; passwordFile = mkOption { - type = types.str; + type = types.pathWith { inStore = false; }; example = "/run/my-secret"; 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; example = "ou=people,ou=accounts,dc=example,dc=com"; 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 [ - "sub" "base" "one" + "sub" ]; default = "sub"; 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. ''; }; @@ -395,6 +406,17 @@ in ''; }; + 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"; @@ -405,6 +427,19 @@ in 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 = { @@ -413,11 +448,12 @@ in default = "mail=%{user}"; example = "(&(objectClass=inetOrgPerson)(mail=%{user}))"; description = '' - Filter for user lookups in Dovecot. + LDAP filter used for LMTP delivery from Postfix and post-login + information construction, like the home directory. - See the user_filter reference at - https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-filter - in the Dovecot manual. + See the [user_filter] reference at in the Dovecot manual. + + [user_filter]: https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-filter ''; }; @@ -426,11 +462,12 @@ in default = "mail=%{user}"; example = "(&(objectClass=inetOrgPerson)(mail=%{user}))"; 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 - https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-filter - in the Dovecot manual. + See the [pass_filter] reference in the Dovecot manual. + + [pass_filter]: https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-filter ''; }; }; @@ -438,29 +475,14 @@ in postfix = { filter = mkOption { type = types.str; - default = "mail=%s"; + default = with cfg.ldap.attributes; "${mail}=%s"; + defaultText = lib.literalExpression '' + with config.mailserver.ldap.attributes; "''${mail}=%s"; + ''; example = "(&(objectClass=inetOrgPerson)(mail=%s))"; description = '' - LDAP filter used to search for an account by mail, where - `%s` is a substitute for the address in - question. - ''; - }; - - uidAttribute = mkOption { - type = types.str; - default = "mail"; - example = "uid"; - description = '' - The LDAP attribute referencing the account name for a user. - ''; - }; - - mailAttribute = mkOption { - type = types.str; - default = "mail"; - description = '' - The LDAP attribute holding mail addresses for a user. + LDAP filter used to search for an account by mail, where `%s` is a + substitute for the address in question. ''; }; }; @@ -1640,5 +1662,16 @@ in (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" ] + ) ]; } diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 148c97d..124ecf6 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -71,12 +71,12 @@ let tls = yes ''} tls_require_cert = hard - tls_ca_cert_file = ${cfg.ldap.tlsCAFile} + tls_ca_cert_file = ${cfg.ldap.caFile} dn = ${cfg.ldap.bind.dn} sasl_bind = no auth_bind = yes - base = ${cfg.ldap.searchBase} - scope = ${mkLdapSearchScope cfg.ldap.searchScope} + base = ${cfg.ldap.base} + scope = ${mkLdapSearchScope cfg.ldap.scope} user_attrs = \ ${ldapUuidAttribute}=${ldapUuidAttribute}, \ =home=/var/vmail/ldap/%{ldap:${ldapUuidAttribute}}, \ diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index d860cbd..bf51b63 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -209,11 +209,11 @@ 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} @@ -222,7 +222,7 @@ let 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.mail} ''; ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf"; appendPwdInSenderLoginMap = appendLdapBindPwd { @@ -236,7 +236,7 @@ let 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 { diff --git a/tests/ldap.nix b/tests/ldap.nix index d2e89fa..7402a67 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -110,8 +110,8 @@ in dn = "cn=mail,dc=example"; passwordFile = "/etc/bind-password"; }; - searchBase = "ou=users,dc=example"; - searchScope = "sub"; + base = "ou=users,dc=example"; + scope = "sub"; }; forwards = { From 762f553643e7db2d610238848fc55c3e104cf5f7 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 12 Mar 2026 02:33:06 +0100 Subject: [PATCH 06/10] 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. --- default.nix | 18 +++++++++++++----- docs/release-notes.rst | 5 +++++ mail-server/postfix.nix | 2 +- tests/ldap.nix | 32 +++++++++++++++++--------------- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/default.nix b/default.nix index d8c39d0..0aa69fb 100644 --- a/default.nix +++ b/default.nix @@ -445,8 +445,11 @@ in dovecot = { userFilter = mkOption { type = types.str; - default = "mail=%{user}"; - example = "(&(objectClass=inetOrgPerson)(mail=%{user}))"; + default = with cfg.ldap.attributes; "(|(${mail}=%{user})(${username}=%{user}))"; + defaultText = literalExpression '' + with config.mailserver.ldap.attributes; "(|(''${mail}=%{user})(''${username}=%{user}))"; + ''; + example = "(|(mail=%{user})(uid=%{user}))"; description = '' LDAP filter used for LMTP delivery from Postfix and post-login information construction, like the home directory. @@ -459,8 +462,13 @@ in passFilter = mkOption { type = types.nullOr types.str; - default = "mail=%{user}"; - example = "(&(objectClass=inetOrgPerson)(mail=%{user}))"; + default = with cfg.ldap.attributes; "${username}=%{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 = '' LDAP filter used to restrict which users are eligible to authenticate against Dovecot. @@ -479,7 +487,7 @@ in defaultText = lib.literalExpression '' with config.mailserver.ldap.attributes; "''${mail}=%s"; ''; - example = "(&(objectClass=inetOrgPerson)(mail=%s))"; + example = "(mail=%s)"; description = '' LDAP filter used to search for an account by mail, where `%s` is a substitute for the address in question. diff --git a/docs/release-notes.rst b/docs/release-notes.rst index c4c0fb9..d1094a4 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -22,6 +22,11 @@ NixOS 26.05 established by `agenix`_/`sops-nix`_ that instead rely on encryption. This option prevents files from leaking in to the Nix store. See :option:`mailserver.loginAccounts..passwordFile`. +- 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 release: diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index bf51b63..d131fba 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -222,7 +222,7 @@ let ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" '' ${commonLdapConfig} query_filter = ${cfg.ldap.postfix.filter} - result_attribute = ${cfg.ldap.attributes.mail} + result_attribute = ${cfg.ldap.attributes.username} ''; ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf"; appendPwdInSenderLoginMap = appendLdapBindPwd { diff --git a/tests/ldap.nix b/tests/ldap.nix index 7402a67..8349d70 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -74,7 +74,7 @@ in dn: cn=alice,ou=users,dc=example entryUUID: c52f777b-a6e8-4507-80f9-c4de47e8520d objectClass: inetOrgPerson - cn: alice + uid: alice sn: Foo mail: alice@example.com userPassword: ${alicePassword} @@ -83,7 +83,6 @@ in entryUUID: f3b4e8ea-087f-42cc-95f0-cbfd99386092 objectClass: inetOrgPerson objectClass: posixAccount - cn: bob uid: bob uidNumber: 9999 gidNumber: 9999 @@ -161,16 +160,19 @@ 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.vmailUID}") machine.succeed("doveadm user -f gid bob@example.com | grep ${toString nodes.machine.mailserver.vmailUID}") @@ -187,16 +189,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([ @@ -204,9 +206,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}')", @@ -220,9 +222,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}')", @@ -236,7 +238,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", @@ -245,7 +247,7 @@ 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'") ''; } From 63365fb1a8e100e8e67bdd8470c238b475805bc0 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 12 Mar 2026 12:45:38 +0100 Subject: [PATCH 07/10] postfix: document ldap map purposes --- mail-server/postfix.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index d131fba..8164c3e 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -219,6 +219,7 @@ let 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} @@ -233,6 +234,7 @@ 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} From a70ae543cb6b04bc8319689f7b8ce17d6c1beebb Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 12 Mar 2026 14:58:17 +0100 Subject: [PATCH 08/10] docs: add baseline ldap documentation within the new account backends nav section. --- docs/index.rst | 7 +++- docs/ldap-attrs.nix | 12 +++++++ docs/ldap-basic.nix | 17 +++++++++ docs/ldap.rst | 88 +++++++++++++++++++++++++++++++++++++++------ 4 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 docs/ldap-attrs.nix create mode 100644 docs/ldap-basic.nix diff --git a/docs/index.rst b/docs/index.rst index 5f2c53f..c01f7fe 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,13 +21,18 @@ 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:: diff --git a/docs/ldap-attrs.nix b/docs/ldap-attrs.nix new file mode 100644 index 0000000..279a3ad --- /dev/null +++ b/docs/ldap-attrs.nix @@ -0,0 +1,12 @@ +{ + mailserver = { + ldap = { + attributes = { + uuid = "entryUUID"; + username = "uid"; + password = "userPassword"; + mail = "mail"; + }; + }; + }; +} diff --git a/docs/ldap-basic.nix b/docs/ldap-basic.nix new file mode 100644 index 0000000..73008be --- /dev/null +++ b/docs/ldap-basic.nix @@ -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"; + }; + }; +} diff --git a/docs/ldap.rst b/docs/ldap.rst index efd975d..4750e31 100644 --- a/docs/ldap.rst +++ b/docs/ldap.rst @@ -1,14 +1,82 @@ -LDAP Support -============ +LDAP +==== -It is possible to manage mail user accounts with LDAP rather than with -the option `loginAccounts `_. +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. -All related LDAP options are described in the `LDAP options section -`_ and the `LDAP test -`_ -provides a getting started example. -.. note:: - The LDAP support can not be enabled if some accounts are also defined with ``mailserver.loginAccounts``. +Requirements +~~~~~~~~~~~~ +To enable the LDAP integration the following requirements must be fulfilled: + +- Existing LDAP service (we currently only test against OpenLDAP) +- Bind credentials against LDAP with permissions to + + - search for the acceptable set of users + - read the :option:`mailserver.ldap.attributes.password` attribute + +- Each user entry must provide attributes that can serve as + + - :option:`mailserver.ldap.attributes.mail` (primary mail address) + - :option:`mailserver.ldap.attributes.username` (login name) + - :option:`mailserver.ldap.attributes.password` (login password) + - :option:`mailserver.ldap.attributes.uuid` (stable identifier) + + +Features +~~~~~~~~ + +We currently have a basic feature set covering user accounts only and try to +follow best practices to simplify maintenance. + +- Users authenticate with the username and password attribute +- Maildir storage paths are constructed using the uuid attribute +- Primary mail address read from mail attribute + + +Limitations +~~~~~~~~~~~ + +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 From 59eae7f3d087f7999278b7bf8eae0ba8ba55a6ec Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Thu, 12 Mar 2026 15:13:24 +0100 Subject: [PATCH 09/10] tests/ldap: remove redundant settings All of thsese are already option defaults. --- tests/ldap.nix | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/tests/ldap.nix b/tests/ldap.nix index 8349d70..a67362b 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -11,17 +11,10 @@ in { pkgs, ... }: { 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} $@ @@ -116,11 +109,6 @@ in forwards = { "bob_fw@example.com" = "bob@example.com"; }; - - vmailGroupName = "vmail"; - vmailUID = 5000; - - enableImap = false; }; }; }; From 98acd76bbf200cc9d71537f0cd10649b51864daa Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sun, 15 Mar 2026 00:31:04 +0100 Subject: [PATCH 10/10] Add migration story for LDAP UUID home directories --- docs/migrations.rst | 105 +++++- docs/release-notes.rst | 4 + mail-server/assertions.nix | 10 + migrations/nixos-mailserver-migration-04.py | 346 ++++++++++++++++++++ 4 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 migrations/nixos-mailserver-migration-04.py diff --git a/docs/migrations.rst b/docs/migrations.rst index 9aff6db..387e1f0 100644 --- a/docs/migrations.rst +++ b/docs/migrations.rst @@ -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 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 +----------- + +#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/``) 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 ----------- diff --git a/docs/release-notes.rst b/docs/release-notes.rst index d1094a4..e3122d4 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -22,6 +22,9 @@ NixOS 26.05 established by `agenix`_/`sops-nix`_ that instead rely on encryption. This option prevents files from leaking in to the Nix store. See :option:`mailserver.loginAccounts..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 @@ -38,6 +41,7 @@ NixOS 26.05 .. _DKIM key management: dkim.html .. _agenix: https://github.com/ryantm/agenix .. _sops-nix: https://github.com/Mic92/sops-nix +.. _UUID based home directories: migrations.html#dovecot-ldap-uuid-based-home-directories NixOS 25.11 ----------- diff --git a/mail-server/assertions.nix b/mail-server/assertions.nix index 561aa63..025658d 100644 --- a/mail-server/assertions.nix +++ b/mail-server/assertions.nix @@ -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. + ''; + } + ] ); } diff --git a/migrations/nixos-mailserver-migration-04.py b/migrations/nixos-mailserver-migration-04.py new file mode 100644 index 0000000..f24b80a --- /dev/null +++ b/migrations/nixos-mailserver-migration-04.py @@ -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/ 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, + )