Files
emilylange eea473ea12 dovecot: reintroduce LDAP bind auth for passdb
LDAP bind auth used to be enabled by default (and not configurable)
before the dovecot 2.4 migration.

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

The use of authentication bind is required for LDAP servers that simply
do not have such LDAP attribute like Kanidm, or in cases where the
password scheme used is not supported by Dovecot.
2026-05-24 02:01:55 +02:00

380 lines
13 KiB
Nix

{
pkgs,
...
}:
let
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
''
mkpasswd -s <<<"$password" > $out
'';
bindPassword = "unsafegibberish";
alicePassword = "testalice";
bobPassword = "testbob";
carolPassword = "testcarol";
malloryPassword = "testmallory";
in
{
name = "ldap";
nodes = {
machine =
{ pkgs, lib, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')
];
environment.etc.bind-password.text = bindPassword;
services.openldap = {
enable = true;
settings = {
children = {
"cn=schema".includes = [
"${pkgs.openldap}/etc/schema/core.ldif"
"${pkgs.openldap}/etc/schema/cosine.ldif"
"${pkgs.openldap}/etc/schema/inetorgperson.ldif"
"${pkgs.openldap}/etc/schema/nis.ldif"
];
"olcDatabase={1}mdb" = {
attrs = {
objectClass = [
"olcDatabaseConfig"
"olcMdbConfig"
];
olcDatabase = "{1}mdb";
olcDbDirectory = "/var/lib/openldap/example";
olcSuffix = "dc=example";
};
};
};
};
declarativeContents."dc=example" =
#ldif
''
dn: dc=example
objectClass: domain
dc: example
dn: cn=mail,dc=example
objectClass: organizationalRole
objectClass: simpleSecurityObject
objectClass: top
cn: mail
# unsafegibberish
userPassword: {SSHA}JNr6l3s/RHo1LKRXqFsJg8sXznyRid8L
dn: ou=users,dc=example
objectClass: organizationalUnit
ou: users
dn: cn=alice,ou=users,dc=example
entryUUID: c52f777b-a6e8-4507-80f9-c4de47e8520d
objectClass: inetOrgPerson
uid: alice
sn: Foo
mail: alice@example.com
# testalice
userPassword: {SSHA}gkJq4Dm4jfIKjxviR0WD63wMt0Ti6zMB
dn: cn=bob,ou=users,dc=example
entryUUID: f3b4e8ea-087f-42cc-95f0-cbfd99386092
objectClass: inetOrgPerson
objectClass: posixAccount
uid: bob
uidNumber: 9999
gidNumber: 9999
sn: Bar
mail: bob@example.com
homeDirectory: /home/bob
# testbob
userPassword: {SSHA}qqUveZGZrDrjYFnREXLDZc//y89RppVN
dn: cn=carol,ou=users,dc=example
entryUUID: 41240499-27e2-4fa2-be4f-4113a77661b1
objectClass: inetOrgPerson
uid: carol
sn: Baz
mail: carol@example.com
# testcarol
userPassword: {SSHA}69HOuP+OPWE+3+tDucFZxzXDC7p4e3ML
dn: cn=frank,ou=users,dc=example
entryUUID: ca16f594-f6b2-418f-87d3-0d02d746461f
objectClass: inetOrgPerson
uid: frank
sn: Moo
mail: frank@example.com
# testfrank
userPassword: {SSHA}xqtMl8/uJ6HEFWDzLYpAE+Wq7FvKrtkm
'';
};
systemd.services.dovecot.serviceConfig = {
CacheDirectory = "dovecot";
StateDirectory = "dovecot";
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" ];
localDnsResolver = false;
storage.path = "/var/lib/dovecot/vmail";
indexDir = "/var/cache/dovecot/indices";
aliases = {
# Steal frank@example.com from LDAP user frank
"frank@example.com" = "mallory@example.com";
};
accounts = {
# Colliding local account takes precedence over LDAP account with
# same address.
"carol@example.com" = {
hashedPasswordFile = hashPassword carolPassword;
};
# Another account used as a virtual alias target to steal
# frank@example.com from the LDAP user frank
"mallory@example.com" = {
hashedPasswordFile = hashPassword malloryPassword;
};
};
ldap = {
enable = true;
uris = [
"ldap://"
];
bind = {
dn = "cn=mail,dc=example";
passwordFile = "/etc/bind-password";
};
base = "ou=users,dc=example";
scope = "sub";
attributes = {
# disable auth bind
password = "userPassword";
};
};
forwards = {
"bob_fw@example.com" = "bob@example.com";
};
};
specialisation.auth_bind = {
inheritParentConfig = true;
configuration = {
mailserver = {
ldap = {
attributes = {
# enable auth bind
password = lib.mkForce null;
};
};
};
services.openldap.settings.children = {
"olcDatabase={1}mdb" = {
attrs = {
olcAccess = [
# disallow access to userPassword
''
to * attrs=userPassword
by anonymous auth
by * none
''
# default policy (same as if we would specify none as all)
''
to *
by * read
''
];
};
};
};
};
};
};
};
testScript =
{
nodes,
...
}:
# python
''
import sys
import re
machine.start()
machine.wait_for_unit("multi-user.target")
# if the schema is broken, fail fast. helps during development.
machine.wait_for_unit("openldap.service")
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
# This function retrieves the ldap table file from a postconf
# command.
# A key lookup is achieved and the returned value is compared
# to the expected value.
def test_lookup(postconf_cmdline, key, expected):
conf = machine.succeed(postconf_cmdline).rstrip()
ldap_table_path_match = re.match('.* =.*ldap:(.*)', conf)
if not ldap_table_path_match:
raise RuntimeError(f"Failed to match LDAP table in '{postconf_cmdline}' response")
ldap_table_path = ldap_table_path_match.group(1)
value = machine.succeed(f"postmap -q {key} ldap:{ldap_table_path}").rstrip()
try:
assert value == expected
except AssertionError:
print(f"Expected {conf} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr)
raise
with subtest("Test postmap lookups"):
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice")
test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob")
with subtest("Test doveadm lookups"):
machine.succeed("doveadm user -u alice@example.com")
machine.succeed("doveadm user -u bob@example.com")
machine.succeed("doveadm user -u alice")
machine.log(machine.succeed("doveadm user -u bob"))
machine.succeed("doveadm user -f uid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}")
machine.succeed("doveadm user -f gid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}")
machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
machine.succeed("doveadm user -f mail_path bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
machine.succeed("doveadm user -f mail_index_path bob@example.com | grep ${nodes.machine.mailserver.indexDir}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
with subtest("Files containing secrets are only readable by root"):
machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'")
with subtest("Test account/mail address binding via explicit TLS"):
machine.fail(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice",
"--imap-host localhost",
"--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'")
with subtest("Test mail delivery via implicit TLS"):
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice",
"--imap-host localhost",
"--imap-username bob",
"--from-addr alice@example.com",
"--to-addr bob@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf"
]))
with subtest("Test mail forwarding via explicit TLS works"):
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice",
"--imap-host localhost",
"--imap-username bob",
"--from-addr alice@example.com",
"--to-addr bob_fw@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf"
]))
with subtest("Test cannot send mail via implicit TLS from forwarded address"):
machine.fail(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username bob",
"--imap-host localhost",
"--imap-username alice@example.com",
"--from-addr bob_fw@example.com",
"--to-addr alice@example.com",
"--src-password-file <(echo '${bobPassword}')",
"--dst-password-file <(echo '${alicePassword}')",
"--ignore-dkim-spf"
]))
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob'")
with subtest("Local addresses take priority over those learnt from LDAP"):
# carol@example.com is routed to the local user account
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice", # LDAP user
"--imap-host localhost",
"--imap-username carol@example.com", # Local user
"--from-addr alice@example.com",
"--to-addr carol@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${carolPassword}')",
"--ignore-dkim-spf"
]))
# frank@example.com gets routed to mallory@example.com due to a virtual alias
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice", # LDAP user
"--imap-host localhost",
"--imap-username mallory@example.com", # Local user
"--from-addr alice@example.com",
"--to-addr frank@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${malloryPassword}')",
"--ignore-dkim-spf"
]))
with subtest("LDAP Authentication Binds"):
machine.succeed("/run/booted-system/specialisation/auth_bind/bin/switch-to-configuration test")
machine.wait_for_unit("openldap.service")
machine.succeed("doveadm auth test alice '${alicePassword}'")
'';
}