From 93a6542ff76196a5c64e0110cfc6098043b51987 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Tue, 19 Jul 2022 23:45:29 +0200 Subject: [PATCH 1/8] Add support for LDAP users Allow configuring lookups for users and their mail addresses from an LDAP directory. The LDAP username will be used as an accountname as opposed to the email address used as the `loginName` for declarative accounts. Mailbox for LDAP users will be stored below `/var/vmail/ldap/`. Configuring domains is out of scope, since domains require further configuration within the NixOS mailserver construct to set up all related services accordingly. Aliases can already be configured using `mailserver.forwards` but could be supported using LDAP at a later point. --- default.nix | 150 ++++++++++++++++++++++++++++++++++++++++ mail-server/dovecot.nix | 50 ++++++++++++++ mail-server/postfix.nix | 37 +++++++++- 3 files changed, 235 insertions(+), 2 deletions(-) diff --git a/default.nix b/default.nix index 2dfbbfb..4b684d1 100644 --- a/default.nix +++ b/default.nix @@ -198,6 +198,156 @@ in default = {}; }; + ldap = { + enable = mkEnableOption "LDAP support"; + + uris = mkOption { + type = types.listOf types.str; + example = literalExpression '' + [ + "ldaps://ldap1.example.com" + "ldaps://ldap2.example.com" + ] + ''; + description = '' + URIs where your LDAP server can be reached + ''; + }; + + startTls = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable StartTLS upon connection to the server. + ''; + }; + + tlsCAFile = mkOption { + type = types.path; + default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + description = '' + Certifificate trust anchors used to verify the LDAP server certificate. + ''; + }; + + bind = { + dn = mkOption { + 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. + ''; + }; + + password = mkOption { + type = types.str; + example = "not$4f3"; + description = '' + Password required to authenticate against the LDAP servers. + ''; + }; + }; + + searchBase = mkOption { + type = types.str; + example = "ou=people,ou=accounts,dc=example,dc=com"; + description = '' + Base DN at below which to search for users accounts. + ''; + }; + + searchScope = mkOption { + type = types.enum [ "sub" "base" "one" ]; + default = "sub"; + description = '' + Search scope below which users accounts are looked for. + ''; + }; + + dovecot = { + userAttrs = mkOption { + type = types.str; + default = ""; + description = '' + LDAP attributes to be retrieved during userdb lookups. + + See the users_attrs reference at + https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#user-attrs + in the Dovecot manual. + ''; + }; + + userFilter = mkOption { + type = types.str; + default = "cn=%u"; + example = "(&(objectClass=inetOrgPerson)(cn=%u))"; + description = '' + Filter for user lookups in Dovecot. + + See the user_filter reference at + https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#user-filter + in the Dovecot manual. + ''; + }; + + 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/configuration_manual/authentication/ldap_settings_auth/#pass-attrs + in the Dovecot manual. + ''; + }; + + passFilter = mkOption { + type = types.str; + default = "cn=%u"; + example = "(&(objectClass=inetOrgPerson)(cn=%u))"; + description = '' + Filter for password lookups in Dovecot. + + See the pass_filter reference for + https://doc.dovecot.org/configuration_manual/authentication/ldap_settings_auth/#pass-filter + in the Dovecot manual. + ''; + }; + }; + + postfix = { + filter = mkOption { + type = types.str; + default = "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 = "cn"; + 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. + ''; + }; + }; + }; + indexDir = mkOption { type = types.nullOr types.str; default = null; diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 01563e0..3ffa232 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -99,6 +99,12 @@ let # The assertion garantees there is exactly one Junk mailbox. junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else ""; + mkLdapSearchScope = scope: ( + if scope == "sub" then "subtree" + else if scope == "one" then "onelevel" + else scope + ); + in { config = with cfg; lib.mkIf enable { @@ -229,6 +235,19 @@ in default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory} } + ${lib.optionalString cfg.ldap.enable '' + passdb { + driver = ldap + args = /etc/dovecot/dovecot-ldap.conf.ext + } + + userdb { + driver = ldap + args = /etc/dovecot/dovecot-ldap.conf.ext + default_fields = home=/var/vmail/ldap/%u uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID} + } + ''} + service auth { unix_listener auth { mode = 0660 @@ -291,6 +310,37 @@ in ''; }; + environment.etc = lib.optionalAttrs (cfg.ldap.enable) { + "dovecot/dovecot-ldap.conf.ext" = { + mode = "0600"; + uid = config.ids.uids.dovecot2; + gid = config.ids.gids.dovecot2; + text = '' + ldap_version = 3 + uris = ${lib.concatStringsSep " " cfg.ldap.uris} + ${lib.optionalString cfg.ldap.startTls '' + tls = yes + ''} + tls_require_cert = hard + tls_ca_cert_file = ${cfg.ldap.tlsCAFile} + dn = ${cfg.ldap.bind.dn} + dnpass = ${cfg.ldap.bind.password} + sasl_bind = no + auth_bind = yes + base = ${cfg.ldap.searchBase} + scope = ${mkLdapSearchScope cfg.ldap.searchScope} + ${lib.optionalString (cfg.ldap.dovecot.userAttrs != "") '' + user_attrs = ${cfg.ldap.dovecot.user_attrs} + ''} + user_filter = ${cfg.ldap.dovecot.userFilter} + ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' + pass_attrs = ${cfg.ldap.dovecot.passAttrs} + ''} + pass_filter = ${cfg.ldap.dovecot.passFilter} + ''; + }; + }; + systemd.services.dovecot2 = { preStart = '' ${genPasswdScript} diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index 340122b..9ccf7bb 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -133,11 +133,40 @@ let smtpd_sasl_security_options = "noanonymous"; smtpd_sasl_local_domain = "$myhostname"; smtpd_client_restrictions = "permit_sasl_authenticated,reject"; - smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts"; + smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMap}"}"; smtpd_sender_restrictions = "reject_sender_login_mismatch"; smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; cleanup_service_name = "submission-header-cleanup"; }; + + commonLdapConfig = lib.optionalString (cfg.ldap.enable) '' + 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_require_cert = yes + + search_base = ${cfg.ldap.searchBase} + scope = ${cfg.ldap.searchScope} + + bind = yes + bind_dn = ${cfg.ldap.bind.dn} + bind_pw = ${cfg.ldap.bind.password} + ''; + + ldapSenderLoginMap = lib.optionalString (cfg.ldap.enable) + (pkgs.writeText "ldap-sender-login-map.cf" '' + ${commonLdapConfig} + query_filter = ${cfg.ldap.postfix.filter} + result_attribute = ${cfg.ldap.postfix.uidAttribute} + ''); + + ldapVirtualMailboxMap = lib.optionalString (cfg.ldap.enable) + (pkgs.writeText "ldap-virtual-mailbox-map.cf" '' + ${commonLdapConfig} + query_filter = ${cfg.ldap.postfix.filter} + result_attribute = ${cfg.ldap.postfix.uidAttribute} + ''); in { config = with cfg; lib.mkIf enable { @@ -170,7 +199,11 @@ in virtual_gid_maps = "static:5000"; virtual_mailbox_base = mailDirectory; virtual_mailbox_domains = vhosts_file; - virtual_mailbox_maps = mappedFile "valias"; + virtual_mailbox_maps = [ + (mappedFile "valias") + ] ++ lib.optionals (cfg.ldap.enable) [ + "ldap:${ldapVirtualMailboxMap}" + ]; virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients lmtp_destination_recipient_limit = "1"; From 975b6fca35511a37735bf189a1bf254dff36c6db Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 12 Aug 2022 18:54:12 +0200 Subject: [PATCH 2/8] scripts/mail-check: allow passing the smtp username Will be prefered over the from address when specified. --- scripts/mail-check.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/mail-check.py b/scripts/mail-check.py index f5a97a5..0a96ce1 100644 --- a/scripts/mail-check.py +++ b/scripts/mail-check.py @@ -9,7 +9,7 @@ import time RETRY = 100 -def _send_mail(smtp_host, smtp_port, from_addr, from_pwd, to_addr, subject, starttls): +def _send_mail(smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls): print("Sending mail with subject '{}'".format(subject)) message = "\n".join([ "From: {from_addr}", @@ -30,7 +30,7 @@ def _send_mail(smtp_host, smtp_port, from_addr, from_pwd, to_addr, subject, star if starttls: smtp.starttls() if from_pwd is not None: - smtp.login(from_addr, from_pwd) + smtp.login(smtp_username or from_addr, from_pwd) smtp.sendmail(from_addr, [to_addr], message) return @@ -141,6 +141,7 @@ def send_and_read(args): _send_mail(smtp_host=args.smtp_host, smtp_port=args.smtp_port, + smtp_username=args.smtp_username, from_addr=args.from_addr, from_pwd=src_pwd, to_addr=args.to_addr, @@ -171,6 +172,7 @@ parser_send_and_read = subparsers.add_parser('send-and-read', description="Send parser_send_and_read.add_argument('--smtp-host', type=str) parser_send_and_read.add_argument('--smtp-port', type=str, default=25) parser_send_and_read.add_argument('--smtp-starttls', action='store_true') +parser_send_and_read.add_argument('--smtp-username', type=str, default='', help="username used for smtp login. If not specified, the from-addr value is used") parser_send_and_read.add_argument('--from-addr', type=str) parser_send_and_read.add_argument('--imap-host', required=True, type=str) parser_send_and_read.add_argument('--imap-port', type=str, default=993) From c35e3c96a3acb23539802d4c70fd06d694b4ab84 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 12 Aug 2022 18:54:49 +0200 Subject: [PATCH 3/8] Create LDAP test Sets up a declaratively configured OpenLDAP instance with users alice and bob. They each own one email address, First we test that postfix can communicate with LDAP and do the expected lookups using the defined maps. Then we use doveadm to make sure it can look up the two accounts. Next we check the binding between account and mail address, by logging in as alice and trying to send from bob@example.com, which alice is not allowed to do. We expect postfix to reject the sender address here. Finally we check mail delivery between alice and bob. Alice tries to send a mail from alice@example.com to bob@example.com and bob then checks whether it arrived in their mailbox. --- flake.nix | 1 + tests/ldap.nix | 172 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 tests/ldap.nix diff --git a/flake.nix b/flake.nix index 45b84a7..3cbc8ed 100644 --- a/flake.nix +++ b/flake.nix @@ -30,6 +30,7 @@ "external" "clamav" "multiple" + "ldap" ]; genTest = testName: release: { "name"= "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}"; diff --git a/tests/ldap.nix b/tests/ldap.nix new file mode 100644 index 0000000..6077543 --- /dev/null +++ b/tests/ldap.nix @@ -0,0 +1,172 @@ +{ pkgs ? import {} +, ... +}: + +let + bindPassword = "unsafegibberish"; + alicePassword = "testalice"; + bobPassword = "testbob"; +in +pkgs.nixosTest { + name = "ldap"; + nodes = { + machine = { config, pkgs, ... }: { + imports = [ + ./../default.nix + ./lib/config.nix + ]; + + virtualisation.memorySize = 1024; + + environment.systemPackages = [ + (pkgs.writeScriptBin "mail-check" '' + ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ + '')]; + + 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"; + olcSuffix = "dc=example"; + }; + }; + }; + }; + declarativeContents."dc=example" = '' + dn: dc=example + objectClass: domain + dc: example + + dn: cn=mail,dc=example + objectClass: organizationalRole + objectClass: simpleSecurityObject + objectClass: top + cn: mail + userPassword: ${bindPassword} + + dn: ou=users,dc=example + objectClass: organizationalUnit + ou: users + + dn: cn=alice,ou=users,dc=example + objectClass: inetOrgPerson + cn: alice + sn: Foo + mail: alice@example.com + userPassword: ${alicePassword} + + dn: cn=bob,ou=users,dc=example + objectClass: inetOrgPerson + cn: bob + sn: Bar + mail: bob@example.com + userPassword: ${bobPassword} + ''; + }; + + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" ]; + localDnsResolver = false; + + ldap = { + enable = true; + uris = [ + "ldap://" + ]; + bind = { + dn = "cn=mail,dc=example"; + password = bindPassword; + }; + searchBase = "ou=users,dc=example"; + searchScope = "sub"; + }; + + vmailGroupName = "vmail"; + vmailUID = 5000; + + enableImap = false; + }; + }; + }; + testScript = '' + import sys + + from glob import glob + + machine.start() + machine.wait_for_unit("multi-user.target") + + def test_lookup(map, key, expected): + path = glob(f"/nix/store/*-{map}")[0] + value = machine.succeed(f"postmap -q alice@example.com ldap:{path}").rstrip() + try: + assert value == expected + except AssertionError: + print(f"Expected {map} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr) + raise + + + with subtest("Test postmap lookups"): + test_lookup("ldap-virtual-mailbox-map.cf", "alice@example.com", "alice") + test_lookup("ldap-sender-login-map.cf", "alice", "alice") + + test_lookup("ldap-virtual-mailbox-map.cf", "bob@example.com", "alice") + test_lookup("ldap-sender-login-map.cf", "bob", "alice") + + with subtest("Test doveadm lookups"): + out = machine.succeed("doveadm user -u alice") + machine.log(out) + + out = machine.succeed("doveadm user -u bob") + machine.log(out) + + with subtest("Test account/mail address binding"): + machine.fail(" ".join([ + "mail-check send-and-read", + "--smtp-port 587", + "--smtp-starttls", + "--smtp-host localhost", + "--smtp-username alice", + "--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"): + 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@example.com", + "--src-password-file <(echo '${alicePassword}')", + "--dst-password-file <(echo '${bobPassword}')", + "--ignore-dkim-spf" + ])) + ''; +} From f7a800ff8ce558380a4fc0cf6170890ff97cea92 Mon Sep 17 00:00:00 2001 From: Antoine Eiche Date: Fri, 19 May 2023 10:08:50 +0200 Subject: [PATCH 4/8] Make the ldap test working - The smtp/imap user name is now user@domain.tld - Make the test_lookup function much more robust: it was now getting the correct file from the store. --- default.nix | 12 +++++------ mail-server/postfix.nix | 2 +- tests/ldap.nix | 47 +++++++++++++++++++++++------------------ 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/default.nix b/default.nix index 4b684d1..86b436d 100644 --- a/default.nix +++ b/default.nix @@ -280,8 +280,8 @@ in userFilter = mkOption { type = types.str; - default = "cn=%u"; - example = "(&(objectClass=inetOrgPerson)(cn=%u))"; + default = "mail=%u"; + example = "(&(objectClass=inetOrgPerson)(mail=%u))"; description = '' Filter for user lookups in Dovecot. @@ -304,9 +304,9 @@ in }; passFilter = mkOption { - type = types.str; - default = "cn=%u"; - example = "(&(objectClass=inetOrgPerson)(cn=%u))"; + type = types.nullOr types.str; + default = "mail=%u"; + example = "(&(objectClass=inetOrgPerson)(mail=%u))"; description = '' Filter for password lookups in Dovecot. @@ -331,7 +331,7 @@ in uidAttribute = mkOption { type = types.str; - default = "cn"; + default = "mail"; example = "uid"; description = '' The LDAP attribute referencing the account name for a user. diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index 9ccf7bb..576b8f7 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -158,7 +158,7 @@ let (pkgs.writeText "ldap-sender-login-map.cf" '' ${commonLdapConfig} query_filter = ${cfg.ldap.postfix.filter} - result_attribute = ${cfg.ldap.postfix.uidAttribute} + result_attribute = ${cfg.ldap.postfix.mailAttribute} ''); ldapVirtualMailboxMap = lib.optionalString (cfg.ldap.enable) diff --git a/tests/ldap.nix b/tests/ldap.nix index 6077543..6c8308d 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -18,6 +18,11 @@ pkgs.nixosTest { virtualisation.memorySize = 1024; + services.openssh = { + enable = true; + permitRootLogin = "yes"; + }; + environment.systemPackages = [ (pkgs.writeScriptBin "mail-check" '' ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ @@ -106,35 +111,35 @@ pkgs.nixosTest { }; testScript = '' import sys - - from glob import glob + import re machine.start() machine.wait_for_unit("multi-user.target") - def test_lookup(map, key, expected): - path = glob(f"/nix/store/*-{map}")[0] - value = machine.succeed(f"postmap -q alice@example.com ldap:{path}").rstrip() + # This function retrieves the ldap table file from a postconf + # command. + # A key lookup is achived and the returned value is compared + # to the expected value. + def test_lookup(postconf_cmdline, key, expected): + conf = machine.succeed(postconf_cmdline).rstrip() + ldap_table_path = re.match('.* =.*ldap:(.*)', conf).group(1) + value = machine.succeed(f"postmap -q {key} ldap:{ldap_table_path}").rstrip() try: assert value == expected except AssertionError: - print(f"Expected {map} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr) + 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("ldap-virtual-mailbox-map.cf", "alice@example.com", "alice") - test_lookup("ldap-sender-login-map.cf", "alice", "alice") + 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("ldap-virtual-mailbox-map.cf", "bob@example.com", "alice") - test_lookup("ldap-sender-login-map.cf", "bob", "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") with subtest("Test doveadm lookups"): - out = machine.succeed("doveadm user -u alice") - machine.log(out) - - out = machine.succeed("doveadm user -u bob") - machine.log(out) + machine.succeed("doveadm user -u alice@example.com") + machine.succeed("doveadm user -u bob@example.com") with subtest("Test account/mail address binding"): machine.fail(" ".join([ @@ -142,16 +147,16 @@ pkgs.nixosTest { "--smtp-port 587", "--smtp-starttls", "--smtp-host localhost", - "--smtp-username alice", + "--smtp-username alice@example.com", "--imap-host localhost", - "--imap-username bob", + "--imap-username bob@example.com", "--from-addr bob@example.com", "--to-addr aliceb@example.com", "--src-password-file <(echo '${alicePassword}')", "--dst-password-file <(echo '${bobPassword}')", "--ignore-dkim-spf" ])) - machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice'") + machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'") with subtest("Test mail delivery"): machine.succeed(" ".join([ @@ -159,9 +164,9 @@ pkgs.nixosTest { "--smtp-port 587", "--smtp-starttls", "--smtp-host localhost", - "--smtp-username alice", + "--smtp-username alice@example.com", "--imap-host localhost", - "--imap-username bob", + "--imap-username bob@example.com", "--from-addr alice@example.com", "--to-addr bob@example.com", "--src-password-file <(echo '${alicePassword}')", From 870b1d11879324fea77bafdaa5a848a857b21bb2 Mon Sep 17 00:00:00 2001 From: Antoine Eiche Date: Sat, 20 May 2023 00:12:02 +0200 Subject: [PATCH 5/8] ldap: do not write password to the Nix store --- default.nix | 6 +-- mail-server/common.nix | 21 ++++++++++ mail-server/dovecot.nix | 85 ++++++++++++++++++++++------------------- mail-server/postfix.nix | 53 +++++++++++++++++-------- tests/ldap.nix | 10 ++++- 5 files changed, 114 insertions(+), 61 deletions(-) diff --git a/default.nix b/default.nix index 86b436d..f98b4ad 100644 --- a/default.nix +++ b/default.nix @@ -240,11 +240,11 @@ in ''; }; - password = mkOption { + passwordFile = mkOption { type = types.str; - example = "not$4f3"; + example = "/run/my-secret"; description = '' - Password required to authenticate against the LDAP servers. + A file containing the password required to authenticate against the LDAP servers. ''; }; }; diff --git a/mail-server/common.nix b/mail-server/common.nix index e8beb7a..236530b 100644 --- a/mail-server/common.nix +++ b/mail-server/common.nix @@ -45,4 +45,25 @@ in if value.hashedPasswordFile == null then builtins.toString (mkHashFile name value.hashedPassword) else value.hashedPasswordFile) cfg.loginAccounts; + + # Appends the LDAP bind password to files to avoid writing this + # password into the Nix store. + appendLdapBindPwd = { + name, file, prefix, passwordFile, destination + }: pkgs.writeScript "append-ldap-bind-pwd-in-${name}" '' + #!${pkgs.stdenv.shell} + set -euo pipefail + + baseDir=$(dirname ${destination}) + if (! test -d "$baseDir"); then + mkdir -p $baseDir + chmod 755 $baseDir + fi + + cat ${file} > ${destination} + echo -n "${prefix}" >> ${destination} + cat ${passwordFile} >> ${destination} + chmod 600 ${destination} + ''; + } diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 3ffa232..18b4a50 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -22,9 +22,10 @@ let cfg = config.mailserver; passwdDir = "/run/dovecot2"; - passdbFile = "${passwdDir}/passdb"; + passwdFile = "${passwdDir}/passwd"; userdbFile = "${passwdDir}/userdb"; - + # This file contains the ldap bind password + ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext"; bool2int = x: if x then "1" else "0"; maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs"; @@ -58,6 +59,41 @@ let ''; }; + + ldapConfig = pkgs.writeTextFile { + name = "dovecot-ldap.conf.ext.template"; + text = '' + ldap_version = 3 + uris = ${lib.concatStringsSep " " cfg.ldap.uris} + ${lib.optionalString cfg.ldap.startTls '' + tls = yes + ''} + tls_require_cert = hard + tls_ca_cert_file = ${cfg.ldap.tlsCAFile} + dn = ${cfg.ldap.bind.dn} + sasl_bind = no + auth_bind = yes + base = ${cfg.ldap.searchBase} + scope = ${mkLdapSearchScope cfg.ldap.searchScope} + ${lib.optionalString (cfg.ldap.dovecot.userAttrs != "") '' + user_attrs = ${cfg.ldap.dovecot.user_attrs} + ''} + user_filter = ${cfg.ldap.dovecot.userFilter} + ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' + pass_attrs = ${cfg.ldap.dovecot.passAttrs} + ''} + pass_filter = ${cfg.ldap.dovecot.passFilter} + ''; + }; + + setPwdInLdapConfFile = appendLdapBindPwd { + name = "ldap-conf-file"; + file = ldapConfig; + prefix = "dnpass = "; + passwordFile = cfg.ldap.bind.passwordFile; + destination = ldapConfFile; + }; + genPasswdScript = pkgs.writeScript "generate-password-file" '' #!${pkgs.stdenv.shell} @@ -75,7 +111,7 @@ let fi done - cat < ${passdbFile} + cat < ${passwdFile} ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" ) cfg.loginAccounts)} @@ -90,7 +126,7 @@ let ) cfg.loginAccounts)} EOF - chmod 600 ${passdbFile} + chmod 600 ${passwdFile} chmod 600 ${userdbFile} ''; @@ -226,7 +262,7 @@ in passdb { driver = passwd-file - args = ${passdbFile} + args = ${passwdFile} } userdb { @@ -238,12 +274,12 @@ in ${lib.optionalString cfg.ldap.enable '' passdb { driver = ldap - args = /etc/dovecot/dovecot-ldap.conf.ext + args = ${ldapConfFile} } userdb { driver = ldap - args = /etc/dovecot/dovecot-ldap.conf.ext + args = ${ldapConfFile} default_fields = home=/var/vmail/ldap/%u uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID} } ''} @@ -310,37 +346,6 @@ in ''; }; - environment.etc = lib.optionalAttrs (cfg.ldap.enable) { - "dovecot/dovecot-ldap.conf.ext" = { - mode = "0600"; - uid = config.ids.uids.dovecot2; - gid = config.ids.gids.dovecot2; - text = '' - ldap_version = 3 - uris = ${lib.concatStringsSep " " cfg.ldap.uris} - ${lib.optionalString cfg.ldap.startTls '' - tls = yes - ''} - tls_require_cert = hard - tls_ca_cert_file = ${cfg.ldap.tlsCAFile} - dn = ${cfg.ldap.bind.dn} - dnpass = ${cfg.ldap.bind.password} - sasl_bind = no - auth_bind = yes - base = ${cfg.ldap.searchBase} - scope = ${mkLdapSearchScope cfg.ldap.searchScope} - ${lib.optionalString (cfg.ldap.dovecot.userAttrs != "") '' - user_attrs = ${cfg.ldap.dovecot.user_attrs} - ''} - user_filter = ${cfg.ldap.dovecot.userFilter} - ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' - pass_attrs = ${cfg.ldap.dovecot.passAttrs} - ''} - pass_filter = ${cfg.ldap.dovecot.passFilter} - ''; - }; - }; - systemd.services.dovecot2 = { preStart = '' ${genPasswdScript} @@ -351,10 +356,10 @@ in ${pkgs.dovecot_pigeonhole}/bin/sievec "$k" done chown -R '${dovecot2Cfg.mailUser}:${dovecot2Cfg.mailGroup}' '${stateDir}/imap_sieve' - ''; + '' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile); }; - systemd.services.postfix.restartTriggers = [ genPasswdScript ]; + systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]); systemd.services.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable) { description = "Optimize dovecot indices for fts_xapian"; diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index 576b8f7..ad7ce35 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -133,13 +133,13 @@ let smtpd_sasl_security_options = "noanonymous"; smtpd_sasl_local_domain = "$myhostname"; smtpd_client_restrictions = "permit_sasl_authenticated,reject"; - smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMap}"}"; + smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}"; smtpd_sender_restrictions = "reject_sender_login_mismatch"; smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; cleanup_service_name = "submission-header-cleanup"; }; - commonLdapConfig = lib.optionalString (cfg.ldap.enable) '' + commonLdapConfig = '' server_host = ${lib.concatStringsSep " " cfg.ldap.uris} start_tls = ${if cfg.ldap.startTls then "yes" else "no"} version = 3 @@ -151,26 +151,47 @@ let bind = yes bind_dn = ${cfg.ldap.bind.dn} - bind_pw = ${cfg.ldap.bind.password} ''; - ldapSenderLoginMap = lib.optionalString (cfg.ldap.enable) - (pkgs.writeText "ldap-sender-login-map.cf" '' - ${commonLdapConfig} - query_filter = ${cfg.ldap.postfix.filter} - result_attribute = ${cfg.ldap.postfix.mailAttribute} - ''); + ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" '' + ${commonLdapConfig} + query_filter = ${cfg.ldap.postfix.filter} + result_attribute = ${cfg.ldap.postfix.mailAttribute} + ''; + ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf"; + appendPwdInSenderLoginMap = appendLdapBindPwd { + name = "ldap-sender-login-map"; + file = ldapSenderLoginMap; + prefix = "bind_pw = "; + passwordFile = cfg.ldap.bind.passwordFile; + destination = ldapSenderLoginMapFile; + }; - ldapVirtualMailboxMap = lib.optionalString (cfg.ldap.enable) - (pkgs.writeText "ldap-virtual-mailbox-map.cf" '' - ${commonLdapConfig} - query_filter = ${cfg.ldap.postfix.filter} - result_attribute = ${cfg.ldap.postfix.uidAttribute} - ''); + ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" '' + ${commonLdapConfig} + query_filter = ${cfg.ldap.postfix.filter} + result_attribute = ${cfg.ldap.postfix.uidAttribute} + ''; + ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf"; + appendPwdInVirtualMailboxMap = appendLdapBindPwd { + name = "ldap-virtual-mailbox-map"; + file = ldapVirtualMailboxMap; + prefix = "bind_pw = "; + passwordFile = cfg.ldap.bind.passwordFile; + destination = ldapVirtualMailboxMapFile; + }; in { config = with cfg; lib.mkIf enable { + systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable { + preStart = '' + ${appendPwdInVirtualMailboxMap} + ${appendPwdInSenderLoginMap} + ''; + restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ]; + }; + services.postfix = { enable = true; hostname = "${sendingFqdn}"; @@ -202,7 +223,7 @@ in virtual_mailbox_maps = [ (mappedFile "valias") ] ++ lib.optionals (cfg.ldap.enable) [ - "ldap:${ldapVirtualMailboxMap}" + "ldap:${ldapVirtualMailboxMapFile}" ]; virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients diff --git a/tests/ldap.nix b/tests/ldap.nix index 6c8308d..172a77d 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -28,6 +28,8 @@ pkgs.nixosTest { ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ '')]; + environment.etc.bind-password.text = bindPassword; + services.openldap = { enable = true; settings = { @@ -45,7 +47,7 @@ pkgs.nixosTest { "olcMdbConfig" ]; olcDatabase = "{1}mdb"; - olcDbDirectory = "/var/lib/openldap"; + olcDbDirectory = "/var/lib/openldap/example"; olcSuffix = "dc=example"; }; }; @@ -96,7 +98,7 @@ pkgs.nixosTest { ]; bind = { dn = "cn=mail,dc=example"; - password = bindPassword; + passwordFile = "/etc/bind-password"; }; searchBase = "ou=users,dc=example"; searchScope = "sub"; @@ -141,6 +143,10 @@ pkgs.nixosTest { machine.succeed("doveadm user -u alice@example.com") machine.succeed("doveadm user -u bob@example.com") + with subtest("Files containing secrets are only readable by root"): + machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'") + machine.succeed("ls -l /run/dovecot2/dovecot-ldap.conf.ext | grep -e '-rw------- 1 root root'") + with subtest("Test account/mail address binding"): machine.fail(" ".join([ "mail-check send-and-read", From 014166ef0a19c8e6a3b8f541bde3d16c90f1005a Mon Sep 17 00:00:00 2001 From: Antoine Eiche Date: Sat, 20 May 2023 00:18:58 +0200 Subject: [PATCH 6/8] ldap: improve the documentation --- default.nix | 3 ++- scripts/generate-options.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/default.nix b/default.nix index f98b4ad..2cb7d3d 100644 --- a/default.nix +++ b/default.nix @@ -225,6 +225,7 @@ in tlsCAFile = mkOption { type = types.path; default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + defaultText = lib.literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)"; description = '' Certifificate trust anchors used to verify the LDAP server certificate. ''; @@ -324,7 +325,7 @@ in 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 + `%s` is a substitute for the address in question. ''; }; diff --git a/scripts/generate-options.py b/scripts/generate-options.py index a4973b1..75a25ae 100644 --- a/scripts/generate-options.py +++ b/scripts/generate-options.py @@ -27,6 +27,7 @@ groups = ["mailserver.loginAccounts", "mailserver.dmarcReporting", "mailserver.fullTextSearch", "mailserver.redis", + "mailserver.ldap", "mailserver.monitoring", "mailserver.backup", "mailserver.borgbackup"] From 55a6e97fa441de6e0a965c3dfdcc6ed33d056c63 Mon Sep 17 00:00:00 2001 From: Antoine Eiche Date: Sat, 20 May 2023 09:52:25 +0200 Subject: [PATCH 7/8] ldap: set assertions to forbid ldap and loginAccounts simultaneously --- default.nix | 1 + mail-server/assertions.nix | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 mail-server/assertions.nix diff --git a/default.nix b/default.nix index 2cb7d3d..66b863e 100644 --- a/default.nix +++ b/default.nix @@ -1252,6 +1252,7 @@ in }; imports = [ + ./mail-server/assertions.nix ./mail-server/borgbackup.nix ./mail-server/debug.nix ./mail-server/rsnapshot.nix diff --git a/mail-server/assertions.nix b/mail-server/assertions.nix new file mode 100644 index 0000000..d2c44ea --- /dev/null +++ b/mail-server/assertions.nix @@ -0,0 +1,17 @@ +{ config, lib, pkgs, ... }: +{ + assertions = lib.optionals config.mailserver.ldap.enable [ + { + assertion = config.mailserver.loginAccounts == {}; + message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts"; + } + { + assertion = config.mailserver.extraVirtualAliases == {}; + message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases"; + } + { + assertion = config.mailserver.forwards == {}; + message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.forwards"; + } + ]; +} From caa6ecb54ddbd75a33b72ba242e1762c60cf4cab Mon Sep 17 00:00:00 2001 From: Antoine Eiche Date: Wed, 28 Jun 2023 23:07:11 +0200 Subject: [PATCH 8/8] dovecot: fix a typo on userAttrs --- 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 18b4a50..c9f4ca7 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -76,7 +76,7 @@ let base = ${cfg.ldap.searchBase} scope = ${mkLdapSearchScope cfg.ldap.searchScope} ${lib.optionalString (cfg.ldap.dovecot.userAttrs != "") '' - user_attrs = ${cfg.ldap.dovecot.user_attrs} + user_attrs = ${cfg.ldap.dovecot.userAttrs} ''} user_filter = ${cfg.ldap.dovecot.userFilter} ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") ''