diff --git a/default.nix b/default.nix index 2dfbbfb..66b863e 100644 --- a/default.nix +++ b/default.nix @@ -198,6 +198,157 @@ 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"; + 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. + ''; + }; + + 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. + ''; + }; + + passwordFile = mkOption { + type = types.str; + example = "/run/my-secret"; + description = '' + A file containing the 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 = "mail=%u"; + example = "(&(objectClass=inetOrgPerson)(mail=%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.nullOr types.str; + default = "mail=%u"; + example = "(&(objectClass=inetOrgPerson)(mail=%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 = "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. + ''; + }; + }; + }; + indexDir = mkOption { type = types.nullOr types.str; default = null; @@ -1101,6 +1252,7 @@ in }; imports = [ + ./mail-server/assertions.nix ./mail-server/borgbackup.nix ./mail-server/debug.nix ./mail-server/rsnapshot.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/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"; + } + ]; +} 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 01563e0..c9f4ca7 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.userAttrs} + ''} + user_filter = ${cfg.ldap.dovecot.userFilter} + ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' + pass_attrs = ${cfg.ldap.dovecot.passAttrs} + ''} + pass_filter = ${cfg.ldap.dovecot.passFilter} + ''; + }; + + 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} ''; @@ -99,6 +135,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 { @@ -220,7 +262,7 @@ in passdb { driver = passwd-file - args = ${passdbFile} + args = ${passwdFile} } userdb { @@ -229,6 +271,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 = ${ldapConfFile} + } + + userdb { + driver = ldap + args = ${ldapConfFile} + default_fields = home=/var/vmail/ldap/%u uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID} + } + ''} + service auth { unix_listener auth { mode = 0660 @@ -301,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 340122b..ad7ce35 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -133,15 +133,65 @@ 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:${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 = '' + 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} + ''; + + 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 = 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}"; @@ -170,7 +220,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:${ldapVirtualMailboxMapFile}" + ]; 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"; 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"] 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) diff --git a/tests/ldap.nix b/tests/ldap.nix new file mode 100644 index 0000000..172a77d --- /dev/null +++ b/tests/ldap.nix @@ -0,0 +1,183 @@ +{ 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; + + services.openssh = { + enable = true; + permitRootLogin = "yes"; + }; + + 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" = '' + 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"; + passwordFile = "/etc/bind-password"; + }; + searchBase = "ou=users,dc=example"; + searchScope = "sub"; + }; + + vmailGroupName = "vmail"; + vmailUID = 5000; + + enableImap = false; + }; + }; + }; + testScript = '' + import sys + import re + + machine.start() + machine.wait_for_unit("multi-user.target") + + # 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 {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@example.com") + test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice@example.com") + + 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"): + 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", + "--smtp-port 587", + "--smtp-starttls", + "--smtp-host localhost", + "--smtp-username alice@example.com", + "--imap-host localhost", + "--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@example.com'") + + with subtest("Test mail delivery"): + machine.succeed(" ".join([ + "mail-check send-and-read", + "--smtp-port 587", + "--smtp-starttls", + "--smtp-host localhost", + "--smtp-username alice@example.com", + "--imap-host localhost", + "--imap-username bob@example.com", + "--from-addr alice@example.com", + "--to-addr bob@example.com", + "--src-password-file <(echo '${alicePassword}')", + "--dst-password-file <(echo '${bobPassword}')", + "--ignore-dkim-spf" + ])) + ''; +}