Getting close to working ldap setup with postfix and dovecot
Some checks failed
/ check (push) Failing after 1m44s
/ rust-packages (push) Successful in 2m34s
/ dev-shell (push) Successful in 2m17s
/ terraform-providers (push) Successful in 14m28s
/ systems (push) Successful in 38m13s

LDAP login works for IMAP, but postfix doesn't recognise
the mail addresses for the users.
This commit is contained in:
Kaare Hoff Skovgaard 2025-07-29 00:27:07 +02:00
parent cd8a0db1b6
commit 6a1aca24a9
Signed by: khs
GPG key ID: C7D890804F01E9F0
14 changed files with 245 additions and 376 deletions

View file

@ -26,7 +26,7 @@ in
./tls-rpt.nix
./prometheus.nix
./openid-connect.nix
./package/nixos-module.nix
./ldap.nix
];
config = lib.mkIf cfg.enable {
# TODO: Include a similiar rule for openstack
@ -83,24 +83,28 @@ in
enableImapSsl = true;
enableSubmission = false;
enableSubmissionSsl = true;
stateVersion = 3;
fqdn = config.khscodes.networking.fqdn;
systemDomain = config.khscodes.networking.fqdn;
useUTF8FolderNames = true;
domains = cfg.domains;
certificateScheme = "acme";
};
services.dovecot2.extraConfig = ''
oauth2 {
openid_configuration_url = https://login.kaareskovgaard.net/oauth2/openid/stalwart/.well-known/openid-configuration
auth_mechanisms = $auth_mechanisms oauthbearer xoauth2
passdb {
driver = oauth2
mechanisms = xoauth2 oauthbearer
args = /etc/dovecot/dovecot-oauth2.conf.ext
}
'';
environment.etc."dovecot/dovecot-oauth2.conf.ext".text = ''
scope = email openid profile
username_attribute = preferred_username
client_id = stalwart
client_id = dovecot
client_secret = <${config.khscodes.infrastructure.kanidm-client-application.secretFile}
tokeninfo_url = https://login.kaareskovgaard.net/oauth2/token
introspection_url = https://login.kaareskovgaard.net/oauth2/token/introspect
introspection_mode = post
}
'';
services.prometheus.exporters.postfix = {
enable = true;

View file

@ -0,0 +1,76 @@
{ lib, config, ... }:
let
cfg = config.khscodes.infrastructure.mailserver;
secretFile = "/run/secret/dovecot/ldap";
in
{
options.khscodes.infrastructure.mailserver = {
ldap = {
mount = lib.mkOption {
type = lib.types.str;
};
path = lib.mkOption {
type = lib.types.str;
};
};
};
config = lib.mkIf cfg.enable {
mailserver.debug = true;
mailserver.ldap = {
enable = true;
uris = [ "ldaps://login.kaareskovgaard.net" ];
searchBase = "dc=login,dc=kaareskovgaard,dc=net";
searchScope = "sub";
bind = {
dn = "dn=token";
passwordFile = secretFile;
};
dovecot = {
# Map LDAP uid to dovecot user, and ldap userPassword to dovecot password
passAttrs = "uid=user";
passFilter = "(&(class=account)(memberOf=mail_user)(uid=%u))";
userFilter = "(&(class=account)(memberOf=mail_user)(uid=%u))";
};
postfix = {
filter = "(&(class=account)(memberOf=mail_user)(mail=%s))";
mailAttribute = "mail";
uidAttribute = "uid";
};
};
khscodes.services.vault-agent.templates = [
{
contents = ''
{{- with secret "${cfg.ldap.mount}/data/${cfg.ldap.path}" -}}
{{ .Data.data.apiToken }}
{{- end -}}
'';
destination = secretFile;
owner = "dovecot";
group = "dovecot";
restartUnits = [
"dovecot2.service"
"postfix.service"
];
}
];
# TODO: Include a similiar rule for openstack
khscodes.infrastructure.hetzner-instance.extraFirewallRules = [
{
direction = "out";
protocol = "tcp";
port = 636;
destination_ips = [
"0.0.0.0/0"
"::/0"
];
description = "ldaps";
}
];
khscodes.infrastructure.vault-server-approle.policy = {
"${cfg.ldap.mount}/data/${cfg.ldap.path}" = {
capabilities = [ "read" ];
};
};
};
}

View file

@ -9,12 +9,15 @@ let
cfg = config.khscodes.infrastructure.mailserver;
# Increment this if ever changing mta-sts settings.
policyVersion = 2;
mtaStsWellKnown = pkgs.writeTextFile "mta-sts.txt" ''
mtaStsWellKnown = pkgs.writeTextFile {
name = "mta-sts.txt";
text = ''
version: STSv1
mode: enforce
max_age: 600
mx: ${fqdn}
'';
};
in
{
config = lib.mkIf cfg.enable {

View file

@ -1,23 +0,0 @@
# This file contains patches for Nixos 25.05 to be compatible with new stalwart mail
{
lib,
config,
pkgs,
...
}:
let
configFormat = pkgs.formats.toml { };
configFile = configFormat.generate "stalwart-mail.toml" config.services.stalwart-mail.settings;
in
{
systemd.services.stalwart-mail = lib.mkIf config.services.stalwart-mail.enable {
serviceConfig = {
User = "stalwart-mail";
Group = "stalwart-mail";
ExecStart = lib.mkForce [
""
"${lib.getExe config.services.stalwart-mail.package} --config=${configFile}"
];
};
};
}

View file

@ -1,194 +0,0 @@
{
lib,
rustPlatform,
fetchFromGitHub,
pkg-config,
protobuf,
bzip2,
openssl,
sqlite,
foundationdb,
zstd,
stdenv,
nix-update-script,
nixosTests,
rocksdb,
callPackage,
withFoundationdb ? false,
stalwartEnterprise ? false,
}:
rustPlatform.buildRustPackage (finalAttrs: {
pname = "stalwart-mail" + (lib.optionalString stalwartEnterprise "-enterprise");
version = "0.13.2";
src = fetchFromGitHub {
owner = "stalwartlabs";
repo = "stalwart";
rev = "51a0a1445d74a8cfb880e9d88f5be390fa0e9365";
hash = "sha256-VdeHb1HVGXA5RPenhhK4r/kkQiLG8/4qhdxoJ3xIqR4=";
};
cargoHash = "sha256-Wu6skjs3Stux5nCX++yoQPeA33Qln67GoKcob++Ldng=";
nativeBuildInputs = [
pkg-config
protobuf
rustPlatform.bindgenHook
];
buildInputs = [
bzip2
openssl
sqlite
zstd
]
++ lib.optionals (stdenv.hostPlatform.isLinux && withFoundationdb) [ foundationdb ];
# Issue: https://github.com/stalwartlabs/stalwart/issues/1104
buildNoDefaultFeatures = true;
buildFeatures = [
"postgres"
"rocks"
"elastic"
"redis"
]
++ lib.optionals withFoundationdb [ "foundationdb" ]
++ lib.optionals stalwartEnterprise [ "enterprise" ];
env = {
OPENSSL_NO_VENDOR = true;
ZSTD_SYS_USE_PKG_CONFIG = true;
ROCKSDB_INCLUDE_DIR = "${rocksdb}/include";
ROCKSDB_LIB_DIR = "${rocksdb}/lib";
};
postInstall = ''
mkdir -p $out/etc/stalwart
mkdir -p $out/lib/systemd/system
substitute resources/systemd/stalwart-mail.service $out/lib/systemd/system/stalwart-mail.service \
--replace "__PATH__" "$out"
'';
checkFlags = lib.forEach [
# Require running mysql, postgresql daemon
"directory::imap::imap_directory"
"directory::internal::internal_directory"
"directory::ldap::ldap_directory"
"directory::sql::sql_directory"
"directory::oidc::oidc_directory"
"store::blob::blob_tests"
"store::lookup::lookup_tests"
"smtp::lookup::sql::lookup_sql"
# thread 'directory::smtp::lmtp_directory' panicked at tests/src/store/mod.rs:122:44:
# called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }
"directory::smtp::lmtp_directory"
# thread 'imap::imap_tests' panicked at tests/src/imap/mod.rs:436:14:
# Missing store type. Try running `STORE=<store_type> cargo test`: NotPresent
"imap::imap_tests"
# thread 'jmap::jmap_tests' panicked at tests/src/jmap/mod.rs:303:14:
# Missing store type. Try running `STORE=<store_type> cargo test`: NotPresent
"jmap::jmap_tests"
# Failed to read system DNS config: io error: No such file or directory (os error 2)
"smtp::inbound::data::data"
# Expected "X-My-Header: true" but got Received: from foobar.net (unknown [10.0.0.123])
"smtp::inbound::scripts::sieve_scripts"
# thread 'smtp::outbound::lmtp::lmtp_delivery' panicked at tests/src/smtp/session.rs:313:13:
# Expected "<invalid@domain.org> (failed to lookup" but got From: "Mail Delivery Subsystem" <MAILER-DAEMON@localhost>
"smtp::outbound::lmtp::lmtp_delivery"
# thread 'smtp::outbound::extensions::extensions' panicked at tests/src/smtp/inbound/mod.rs:45:23:
# No queue event received.
"smtp::outbound::extensions::extensions"
# panicked at tests/src/smtp/outbound/smtp.rs:173:5:
"smtp::outbound::smtp::smtp_delivery"
# panicked at tests/src/smtp/outbound/lmtp.rs
"smtp::outbound::lmtp::lmtp_delivery"
# thread 'smtp::queue::retry::queue_retry' panicked at tests/src/smtp/queue/retry.rs:119:5:
# assertion `left == right` failed
# left: [1, 2, 2]
# right: [1, 2, 3]
"smtp::queue::retry::queue_retry"
# thread 'smtp::queue::virtualq::virtual_queue' panicked at /build/source/crates/store/src/dispatch/store.rs:548:14:
# called `Result::unwrap()` on an `Err` value: Error(Event { inner: Store(MysqlError), keys: [(Reason, String("Input/output error: Input/output error: Connection refused (os error 111)")), (CausedBy, String("crates/store/src/dispatch/store.rs:301"))] })
"smtp::queue::virtualq::virtual_queue"
# Missing store type. Try running `STORE=<store_type> cargo test`: NotPresent
"store::store_tests"
# Missing store type. Try running `STORE=<store_type> cargo test`: NotPresent
"cluster::cluster_tests"
# Missing store type. Try running `STORE=<store_type> cargo test`: NotPresent
"webdav::webdav_tests"
# thread 'config::parser::tests::toml_parse' panicked at crates/utils/src/config/parser.rs:463:58:
# called `Result::unwrap()` on an `Err` value: "Expected ['\\n'] but found '!' in value at line 70."
"config::parser::tests::toml_parse"
# error[E0432]: unresolved import `r2d2_sqlite`
# use of undeclared crate or module `r2d2_sqlite`
"backend::sqlite::pool::SqliteConnectionManager::with_init"
# thread 'smtp::reporting::analyze::report_analyze' panicked at tests/src/smtp/reporting/analyze.rs:88:5:
# assertion `left == right` failed
# left: 0
# right: 12
"smtp::reporting::analyze::report_analyze"
# thread 'smtp::inbound::dmarc::dmarc' panicked at tests/src/smtp/inbound/mod.rs:59:26:
# Expected empty queue but got Reload
"smtp::inbound::dmarc::dmarc"
# thread 'smtp::queue::concurrent::concurrent_queue' panicked at tests/src/smtp/inbound/mod.rs:65:9:
# assertion `left == right` failed
"smtp::queue::concurrent::concurrent_queue"
# Failed to read system DNS config: io error: No such file or directory (os error 2)
"smtp::inbound::auth::auth"
# Failed to read system DNS config: io error: No such file or directory (os error 2)
"smtp::inbound::antispam::antispam"
# Failed to read system DNS config: io error: No such file or directory (os error 2)
"smtp::inbound::vrfy::vrfy_expn"
# thread 'smtp::management::queue::manage_queue' panicked at tests/src/smtp/inbound/mod.rs:45:23:
# No queue event received.
# NOTE: Test unreliable on high load systems
"smtp::management::queue::manage_queue"
# thread 'responses::tests::parse_responses' panicked at crates/dav-proto/src/responses/mod.rs:671:17:
# assertion `left == right` failed: failed for 008.xml
# left: ElementEnd
# right: Bytes([...])
"responses::tests::parse_responses"
] (test: "--skip=${test}");
doCheck = !(stdenv.hostPlatform.isLinux && stdenv.hostPlatform.isAarch64);
# Allow network access during tests on Darwin/macOS
__darwinAllowLocalNetworking = true;
passthru = {
inherit rocksdb; # make used rocksdb version available (e.g., for backup scripts)
webadmin = callPackage ./webadmin.nix { };
spam-filter = callPackage ./spam-filter.nix { };
updateScript = nix-update-script { };
tests.stalwart-mail = nixosTests.stalwart-mail;
};
meta = {
description = "Secure & Modern All-in-One Mail Server (IMAP, JMAP, SMTP)";
homepage = "https://github.com/stalwartlabs/mail-server";
changelog = "https://github.com/stalwartlabs/mail-server/blob/main/CHANGELOG.md";
license = [
lib.licenses.agpl3Only
]
++ lib.optionals stalwartEnterprise [
{
fullName = "Stalwart Enterprise License 1.0 (SELv1) Agreement";
url = "https://github.com/stalwartlabs/mail-server/blob/main/LICENSES/LicenseRef-SEL.txt";
free = false;
redistributable = false;
}
];
mainProgram = "stalwart";
maintainers = with lib.maintainers; [
happysalada
onny
oddlama
pandapip1
norpol
];
};
})

View file

@ -1,43 +0,0 @@
{
lib,
fetchFromGitHub,
stdenv,
stalwart-mail,
nix-update-script,
}:
stdenv.mkDerivation (finalAttrs: {
pname = "spam-filter";
version = "2.0.3";
src = fetchFromGitHub {
owner = "stalwartlabs";
repo = "spam-filter";
tag = "v${finalAttrs.version}";
hash = "sha256-NhD/qUiGhgESwR2IOzAHfDATRlgWMcCktlktvVfDONk=";
};
buildPhase = ''
bash ./build.sh
'';
installPhase = ''
mkdir -p $out
cp spam-filter.toml $out/
'';
passthru = {
updateScript = nix-update-script { };
};
meta = {
description = "Secure & modern all-in-one mail server Stalwart (spam-filter module)";
homepage = "https://github.com/stalwartlabs/spam-filter";
changelog = "https://github.com/stalwartlabs/spam-filter/blob/${finalAttrs.src.tag}/CHANGELOG.md";
license = with lib.licenses; [
mit
asl20
];
inherit (stalwart-mail.meta) maintainers;
};
})

View file

@ -1,77 +0,0 @@
{
lib,
rustPlatform,
stalwart-mail,
fetchFromGitHub,
trunk,
tailwindcss_3,
fetchNpmDeps,
nix-update-script,
nodejs,
npmHooks,
llvmPackages,
wasm-bindgen-cli_0_2_93,
binaryen,
zip,
}:
rustPlatform.buildRustPackage (finalAttrs: {
pname = "webadmin";
version = "0.1.31";
src = fetchFromGitHub {
owner = "stalwartlabs";
repo = "webadmin";
rev = "6f1368b8a1160341b385980accea489ee0e45440";
hash = "sha256-/EWn/wiY6zFNhObfo11OkoGufcUODMYs18P3vTBbB8s=";
};
npmDeps = fetchNpmDeps {
name = "${finalAttrs.pname}-npm-deps";
hash = "sha256-na1HEueX8w7kuDp8LEtJ0nD1Yv39cyk6sEMpS1zix2s=";
};
cargoHash = "sha256-Q05+wH9+NfkfmEDJFLuWVQ7wuDeEu9h1XmOMN6SYdyU=";
postPatch = ''
# Using local tailwindcss for compilation
substituteInPlace Trunk.toml --replace-fail "npx tailwindcss" "tailwindcss"
'';
nativeBuildInputs = [
binaryen
llvmPackages.bintools-unwrapped
nodejs
npmHooks.npmConfigHook
tailwindcss_3
trunk
# needs to match with wasm-bindgen version in upstreams Cargo.lock
wasm-bindgen-cli_0_2_93
zip
];
NODE_PATH = "$npmDeps";
buildPhase = ''
trunk build --offline --frozen --release
'';
installPhase = ''
cd dist
mkdir -p $out
zip -r $out/webadmin.zip *
'';
passthru = {
updateScript = nix-update-script { };
};
meta = {
description = "Secure & modern all-in-one mail server Stalwart (webadmin module)";
homepage = "https://github.com/stalwartlabs/webadmin";
changelog = "https://github.com/stalwartlabs/webadmin/blob/${finalAttrs.src.tag}/CHANGELOG.md";
license = lib.licenses.agpl3Only;
inherit (stalwart-mail.meta) maintainers;
};
})

View file

@ -0,0 +1,21 @@
{ pkgs, ... }:
pkgs.writeShellApplication {
name = "kanidm-dovecot-service-account-provision";
runtimeInputs = [
pkgs.openssh
pkgs.openbao
pkgs.uutils-coreutils-noprefix
];
text = ''
tmpfile="$(mktemp)"
exec {fd}>&1
trap 'rm "$tmpfile"' EXIT
api_token="$(ssh -tt security.kaareskovgaard.net -- kanidm-provision-dovecot-service-account | tee >(cat - >&"$fd"))"
# Using tail to do this ends up including a \r character, so remove it.
api_token="$(echo "$api_token" | tail -n1 | head -c-2)"
exec {fd}<&-
echo "$api_token" | bao kv put -mount=kanidm "ldap/dovecot" apiToken=-
>&2 echo "API token stored in vault"
'';
}

View file

@ -29,7 +29,7 @@
khscodes.infrastructure = {
kanidm-client-application = {
enable = true;
appName = "stalwart";
appName = "dovecot";
secretOwner = "dovecot2";
perms = "0600";
};
@ -50,21 +50,11 @@
prefixPath = "dkim";
};
};
ldap = {
mount = "kanidm";
path = "ldap/dovecot";
};
};
services.stalwart-mail.settings.directory.memory = {
type = "memory";
principals = [
{
class = "individual";
name = "khs";
fullName = "Kaare Agerlin Skovgaard";
email = [
"kaare@agerlinskovgaard.dk"
"kaare@agerlin-skovgaard.dk"
];
}
];
};
services.roundcube = {
enable = true;
@ -77,11 +67,11 @@
$config['imap_host'] = "ssl://${config.khscodes.networking.fqdn}";
$config['oauth_provider'] = 'generic';
$config['oauth_provider_name'] = 'Kanidm';
$config['oauth_client_id'] = 'stalwart';
$config['oauth_client_id'] = 'dovecot';
$config['oauth_client_secret'] = file_get_contents("${config.khscodes.infrastructure.kanidm-client-application.secretFile}");
$config['oauth_auth_uri'] = 'https://login.kaareskovgaard.net/ui/oauth2';
$config['oauth_token_uri'] = 'https://login.kaareskovgaard.net/oauth2/token';
$config['oauth_identity_uri'] = 'https://login.kaareskovgaard.net/oauth2/openid/stalwart/userinfo';
$config['oauth_identity_uri'] = 'https://login.kaareskovgaard.net/oauth2/openid/dovecot/userinfo';
$config['oauth_identity_fields'] = ['preferred_username'];
$config['oauth_scope'] = 'email openid profile';
'';

View file

@ -34,7 +34,7 @@ in
exec = lib.getExe zfsLoadKeyScript;
restartUnits = [
"postfix.service"
"dovecot.service"
"dovecot2.service"
"rspamd.service"
];
}

View file

@ -41,3 +41,11 @@ nix run '.#configure-instance' -- security-kaareskovgaard.net
```
Then `nix run '.#bitwarden-to-vault` can transfer the needed Bitwarden secrets to vault, enabling other instances to not rely on Bitwarden.
## Provision Dovecot LDAP service account
Lastly, provision the service account for Dovecot (need to log into OpenBAO with `bao login -method=oidc` first):
```bash
nix run '.#kanidm-dovecot-service-account-provision`
```

View file

@ -33,14 +33,18 @@ let
send "$password\n"
expect eof
EOD
kanidm person credential create-reset-token --url https://login.kaareskovgaard.net "$username"
trap "kanidm logout --url https://login.kaareskovgaard.net" EXIT
kanidm person credential create-reset-token --url https://login.kaareskovgaard.net "$username"
'';
};
in
{
imports = [ ./kanidm_application.nix ];
imports = [
./kanidm_application.nix
./kanidm_dovecot.nix
./kanidm_ldap.nix
];
config = {
khscodes.security.kanidm.applications = {
openbao = {
@ -120,7 +124,7 @@ in
};
};
};
stalwart = {
dovecot = {
allowedRedirectUris = [ "https://mail.kaareskovgaard.net/index.php/login/oauth" ];
landingUri = "https://mail.kaareskovgaard.net";
displayName = "Mail";

View file

@ -0,0 +1,92 @@
{
pkgs,
config,
...
}:
let
domain = "login.kaareskovgaard.net";
provisionServiceAccount = pkgs.writeShellApplication {
runtimeInputs = [
config.services.kanidm.package
pkgs.uutils-coreutils-noprefix
pkgs.jq
pkgs.gnugrep
pkgs.expect
];
name = "kanidm-provision-dovecot-service-account";
text = ''
KANIDM_URL="https://${domain}"
export KANIDM_URL
>&2 echo "Resetting password for idm_admin, please provide sudo password"
password="$(sudo -u kanidm kanidmd recover-account -c /etc/kanidm/server.toml -o json idm_admin 2> /dev/null | grep '^{' | jq --raw-output '.password')"
expect <<EOD >&2
spawn kanidm login --name idm_admin
expect "Enter password"
send "$password\n"
expect eof
EOD
trap "kanidm logout > /dev/null" EXIT
dovecot_svc="$(kanidm service-account get dovecot_ldap)"
if [[ "$dovecot_svc" == "No matching entries" ]]; then
kanidm service-account create dovecot_ldap "Dovecot LDAP" admin >&2
fi
kanidm group set-members idm_mail_servers dovecot_ldap >&2
kanidm logout
password="$(sudo -u kanidm kanidmd recover-account -c /etc/kanidm/server.toml -o json admin 2> /dev/null | grep '^{' | jq --raw-output '.password')"
expect <<EOD >&2
spawn kanidm login --name admin
expect "Enter password"
send "$password\n"
expect eof
EOD
api_token="$(kanidm service-account api-token status dovecot_ldap)"
if [[ "$api_token" != "No api tokens exist" ]]; then
# Output is currently some lines, one of which starts with 'token_id: ' and then followed by the token id. So extract it
token_id="$(echo "$api_token" | awk '/^token_id: /{print $2}')"
kanidm service-account api-token destroy dovecot_ldap "$token_id" >&2
fi
token="$(kanidm service-account api-token generate dovecot_ldap "LDAP")"
echo "$token" | tail -n1
'';
};
setPosixPassword = pkgs.writeShellApplication {
runtimeInputs = [
config.services.kanidm.package
pkgs.uutils-coreutils-noprefix
pkgs.jq
pkgs.gnugrep
pkgs.expect
];
name = "kanidm-set-posix-password";
text = ''
user="''${1:-}"
if [[ "$user" == "" ]]; then
>&2 echo "Usage kanidm-set-posix-password <user>"
exit 1
fi
KANIDM_URL="https://${domain}"
export KANIDM_URL
>&2 echo "Resetting password for idm_admin, please provide sudo password"
password="$(sudo -u kanidm kanidmd recover-account -c /etc/kanidm/server.toml -o json idm_admin 2> /dev/null | grep '^{' | jq --raw-output '.password')"
expect <<EOD >&2
spawn kanidm login --name idm_admin
expect "Enter password"
send "$password\n"
expect eof
EOD
trap "kanidm logout > /dev/null" EXIT
kanidm person posix set --name idm_admin "$user"
'';
};
in
{
config = {
environment.systemPackages = [
provisionServiceAccount
setPosixPassword
];
};
}

View file

@ -0,0 +1,8 @@
{
config = {
services.kanidm.serverSettings = {
ldapbindaddress = "[::]:636";
};
networking.firewall.allowedTCPPorts = [ 636 ];
};
}