From 68fddc2de28773223a5524abd15cf366de9b3257 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Thu, 27 Aug 2026 21:40:03 +0200 Subject: [PATCH 1/5] test: Add Dn type tests --- src/dn.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/dn.rs b/src/dn.rs index d3793f3..58cfaeb 100644 --- a/src/dn.rs +++ b/src/dn.rs @@ -24,6 +24,10 @@ impl Dn { pub fn from_dn_str(input: &str) -> Result { let mut keys: IndexMap> = IndexMap::new(); + if input == "" { + return Ok(Self { keys }); + } + for query in input.split(',') { let mut query_parts = query.split('='); // Here we have key=val pairs @@ -98,3 +102,37 @@ impl Dn { self.keys.insert("dc".to_string(), domain_components); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_dn_str() { + let s = "cn=admin,ou=people,dc=example,dc=com"; + let mut dn = Dn::from_dn_str(s).unwrap(); + + assert_eq!(&dn.to_dn_string(), s); + assert_eq!(dn.get_hostname().as_deref(), Some("example.com")); + dn.set_hostname("a.localhost", false); + assert_eq!(dn.get_hostname().as_deref(), Some("a.localhost")); + assert_eq!(&dn.to_dn_string(), "cn=admin,ou=people,dc=a,dc=localhost"); + } + + #[test] + fn test_empty_dn_str() { + let s = ""; + let mut dn = Dn::from_dn_str(s).unwrap(); + + assert_eq!(&dn.to_dn_string(), s); + assert!(dn.get_hostname().is_none()); + + dn.set_hostname("a.localhost", false); + assert_eq!(dn.get_hostname().as_deref(), None); + assert_eq!(&dn.to_dn_string(), s); + + dn.set_hostname("a.localhost", true); + assert_eq!(dn.get_hostname().as_deref(), Some("a.localhost")); + assert_eq!(&dn.to_dn_string(), "dc=a,dc=localhost"); + } +} From 2a087f922d1c7ea4443808a835facfbcc39a6ec9 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Fri, 28 Aug 2026 11:30:22 +0200 Subject: [PATCH 2/5] feat: Support anonymous bind (for ldapsearch compatibility) --- src/op/bind.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/op/bind.rs b/src/op/bind.rs index cfa9d65..e5ac34a 100644 --- a/src/op/bind.rs +++ b/src/op/bind.rs @@ -34,6 +34,33 @@ pub async fn bind( ) -> Result, LdapError> { trace!("{:?}", lbr); + if lbr.dn == "" { + // Here we pretend to have successfully bound so that + // a client performing an anonymous bind can proceed with + // more requests (such as a search request). + // This supports ldap search which always performs a bind. + let resp_msg = LdapMsg { + msgid, + op: LdapOp::BindResponse(LdapBindResponse { + res: LdapResult { + code: LdapResultCode::Success, + matcheddn: "".to_string(), + message: "".to_string(), + referral: vec![], + }, + saslcreds: None, + }), + ctrl: vec![], + }; + w.send(resp_msg).await.map_err(|err| { + error!("Unable to send response: {err}"); + LdapError::Transport + })?; + // We still treat the client as unbounded because it doesn't + // have a session to a backend. + return Ok(Some(ClientState::Unbound)); + } + let request_dn = lbr.dn.clone(); debug!("Received bind request on DN: {}", lbr.dn); @@ -52,6 +79,7 @@ pub async fn bind( .iter() .find(|x| x.from.to_lowercase() == requested_domain) else { + // TODO: we should probably return an error to the client here debug!("No mapping found for domain {requested_domain}"); return Err(LdapError::InvalidQuery); }; From 39c70d297e5e4c80e39caf71cb4e7d6d33001328 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Fri, 28 Aug 2026 11:32:17 +0200 Subject: [PATCH 3/5] log: Add more info about DN mapping during bind (info log) --- src/op/bind.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/op/bind.rs b/src/op/bind.rs index e5ac34a..7e28aa8 100644 --- a/src/op/bind.rs +++ b/src/op/bind.rs @@ -90,7 +90,7 @@ pub async fn bind( ); dn.set_hostname(&mapping.to, false); lbr.dn = dn.to_dn_string(); - let dn = lbr.dn.clone(); + let backend_dn = lbr.dn.clone(); // We need the client to connect *and* bind to proceed here! let mut client = match BasicLdapClient::build(&mapping.backend).await { @@ -136,10 +136,10 @@ pub async fn bind( }; if valid { - info!("Successful bind for {}", dn); + info!("Successful bind for `{request_dn}` -> `{backend_dn}`"); Ok(Some(ClientState::Authenticated { request_dn, - backend_dn: dn, + backend_dn, client, })) } else { From a30e9c3dd9a3f3526aca05b150ac01ee0519939a Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Fri, 28 Aug 2026 11:37:40 +0200 Subject: [PATCH 4/5] fix: Slightly better error passing to the client --- src/client.rs | 11 ++++------- src/op/bind.rs | 9 ++++++--- src/op/search.rs | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/client.rs b/src/client.rs index 870d622..f7488b3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -33,14 +33,12 @@ impl BasicLdapClient { t } Ok(Err(err)) => { - // trace!(?addr, ?err, "error"); error!("error to {addr}: {err}"); - panic!(); + return Err(LdapError::ConnectError); } Err(_) => { warn!("timeout to {addr}"); - panic!(); - // continue; + return Err(LdapError::Transport); } }; unixstream.into() @@ -53,12 +51,11 @@ impl BasicLdapClient { Ok(Err(err)) => { // trace!(?addr, ?err, "error"); error!("error to {addr}: {err}"); - panic!(); + return Err(LdapError::ConnectError); } Err(_) => { warn!("timeout to {addr}"); - panic!(); - // continue; + return Err(LdapError::Transport); } }; tcpstream.into() diff --git a/src/op/bind.rs b/src/op/bind.rs index 7e28aa8..51b5171 100644 --- a/src/op/bind.rs +++ b/src/op/bind.rs @@ -9,7 +9,9 @@ use std::sync::Arc; use crate::{BasicLdapClient, ClientState, Config, Dn, LdapError}; -pub fn bind_operror(msgid: i32, msg: &str) -> LdapMsg { +// TODO: replace with a more generic approach for different op response types +// (bind, search) and custom error codes +pub fn bind_operror(msgid: i32, msg: &dyn ToString) -> LdapMsg { LdapMsg { msgid, op: LdapOp::BindResponse(LdapBindResponse { @@ -97,7 +99,8 @@ pub async fn bind( Ok(c) => c, Err(e) => { error!("A client build error has occurred: {e:?}"); - let resp_msg = bind_operror(msgid, "unable to bind"); + // TODO: send more detailed error to the client (connection refused / timeout) + let resp_msg = bind_operror(msgid, &"unable to bind"); w.send(resp_msg).await.map_err(|err| { error!("Unable to send response: {err}"); LdapError::Transport @@ -125,7 +128,7 @@ pub async fn bind( } Err(e) => { error!("A client bind error has occurred: {e:?}"); - let resp_msg = bind_operror(msgid, "unable to bind"); + let resp_msg = bind_operror(msgid, &"unable to bind"); w.send(resp_msg).await.map_err(|err| { error!("Unable to send response: {err}"); LdapError::Transport diff --git a/src/op/search.rs b/src/op/search.rs index f5934fc..e7568a6 100644 --- a/src/op/search.rs +++ b/src/op/search.rs @@ -30,7 +30,7 @@ pub async fn search( Ok(data) => data, Err(e) => { error!("A client search error has occurred: {e:?}"); - let resp_msg = bind_operror(msgid, "unable to search"); + let resp_msg = bind_operror(msgid, &"unable to search"); w.send(resp_msg).await.map_err(|err| { error!("Unable to send response: {err}"); LdapError::Transport From c746e594cae91a421b19397fa63e54fd2d86db48 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Fri, 28 Aug 2026 12:03:23 +0200 Subject: [PATCH 5/5] feat: Search by mail attribute (stalwart compatibility) --- README.md | 20 +++++ src/config.rs | 2 + src/main.rs | 51 ++++------- src/op/search.rs | 227 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 265 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a5d019e..3960895 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ Proxy LDAP requests to different LDAP servers based on base DN. Based on code fr - [x] No TLS setup ; use only in trusted networks - [x] LDAP bind requests - [x] LDAP search requests + - [x] extract requested backend from mail attribute filter + - [x] bind to requested backend with mapping `user` and `password` fields + - [ ] **not (yet?) planned:** extract requested backend from more filters + - [x] rewrite the search dn with backend `to` dn (eg. `ou=people,dc=a,dc=localhost` -> `ou=people,dc=example,dc=com`) + - [x] rewrite returned entries with backend dn (eg. `uid=a,ou=people,dc=example,dc=com` -> `uid=a,ou=people,dc=a,dc=localhost`) + - [ ] rewrite search dn and result entries for authenticated searches on a backend - [x] Configurable listening port - [ ] Default fallback to `/etc/ldap-rp/config.toml` - [x] Unix Domain Socket support (incoming requests) @@ -35,10 +41,16 @@ to = "example.com" # - start with anything else for a TCP connection # backend = "/run/lldap/example.com.sock" backend = "127.0.0.1:4389" +# Credentials for performing search query to the backend +# set `lldap_strict_readonly` perms on the account in lldap. +user = "stalwart" +password = "adminadmin" [[mapping]] from = "b.localhost" to = "example.com" backend = "127.0.0.1:5389" +user = "stalwart" +password = "adminadmin" ``` ~~By default, `ldap-rp` will look for a config file in `/etc/ldap-rp/config.toml` but you can change that @@ -61,3 +73,11 @@ ldapwhoami -H ldap://localhost:3389 -D "cn=b,ou=people,dc=a,dc=localhost" -W # with `%2F` and the protocol is changed to `ldapi` ldapwhoami -H ldapi://%2Frun%2Fldap-rp%2Fldap-rp.sock -D "cn=b,ou=people,dc=a,dc=localhost" -W ``` + +You can also perform a search by email without binding with specific credentials, the base dn provided will have its hostname set to the backend mapping's `from` value: + +``` +ldapsearch -x -b "ou=people" -H "ldap://localhost:3389" -s sub "(mail=a@a.localhost)" uid mail +``` + +This is strictly equivalent to using `-b "ou=people,dc=a,dc=localhost"` because the search dn is always overwritten. diff --git a/src/config.rs b/src/config.rs index 4366047..988fc0f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,4 +64,6 @@ pub struct Mapping { pub from: String, pub to: String, pub backend: String, + pub user: String, + pub password: String, } diff --git a/src/main.rs b/src/main.rs index 403e403..5726877 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,8 +63,11 @@ pub async fn client_process( // Start to wait for incoming packets while let Ok(Some(Ok(protomsg))) = timeout(LDAP_CLIENT_IO_TIMEOUT, r.next()).await { + trace!("{:#?}", protomsg); let next_state = match (&mut state, protomsg) { // Doesn't matter what state we are in, any bind will trigger this process. + // TODO: Support anonymous binds for search if config allows it, this will + // allow manual testing with ldapsearch. ( _, LdapMsg { @@ -96,42 +99,20 @@ pub async fn client_process( ctrl, }, ) => { - // We have to trigger a bind first in case we have a mapping. - let lbr = LdapBindRequest { - dn: "".to_string(), - cred: LdapBindCred::Simple("".to_string()), - }; - - let mut next_state = - match op::bind::bind(&mut w, lbr, config.clone(), 0, Vec::default()).await { - Ok(ns) => ns, - Err(_) => break, - }; - - match &mut next_state { - Some(ClientState::Unbound) | None => { - error!("Invalid state, bind did not return an authenticated state!"); - break; - } - Some(ClientState::Authenticated { - client, - request_dn: _, - backend_dn: _, - }) => { - let search_req = op::search::SearchRequest { - sr, - msgid, - ctrl, - client, - }; - match op::search::search(&mut w, search_req).await { - Ok(()) => {} - Err(_) => break, - } - } + // It's very tempting here to just say every email exists, and let stalwart perform + // the bind to check. However, what about *receiving emails*? Do you really want + // to pretend every mailbox exists here instead of letting stalwart know there + // is no such recipient? Do you???? + if let Err(e) = + op::search::search_by_email_attribute(&mut w, sr, msgid, ctrl, config.clone()) + .await + { + debug!("Search unsuccessful: {e:?}"); + break; } - next_state + // We are still not logged in, but we don't abort the session + Some(ClientState::Unbound) } // Authenticated message handler. @@ -155,6 +136,8 @@ pub async fn client_process( client, }; + // TODO: search that's authenticated with a backend should still rewrite + // the search dn and result entries match op::search::search(&mut w, search_req).await { Ok(()) => None, Err(_) => break, diff --git a/src/op/search.rs b/src/op/search.rs index e7568a6..7237f47 100644 --- a/src/op/search.rs +++ b/src/op/search.rs @@ -5,8 +5,10 @@ use ldap3_proto::proto::*; use tokio::io::AsyncWrite; use tokio_util::codec::FramedWrite; +use std::sync::Arc; + use crate::op::bind::bind_operror; -use crate::{BasicLdapClient, LdapError}; +use crate::{BasicLdapClient, Config, Dn, LdapError}; pub struct SearchRequest<'a> { pub sr: LdapSearchRequest, @@ -15,10 +17,231 @@ pub struct SearchRequest<'a> { pub client: &'a mut BasicLdapClient, } +#[derive(Debug)] +pub enum MailDomainError { + /// mail value not user@domain format + InvalidMail, + /// No mail filter found in the search query + NoMailFilter, + /// Filter is not And/Or/Equality for which we can find a mail filter + InvalidFilter, +} + +impl std::fmt::Display for MailDomainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + Self::InvalidMail => { + "No valid email address requested in mail filter in search request" + } + Self::NoMailFilter => "No mail filter found in search request", + Self::InvalidFilter => "No AND/OR/EQUALITY filter found in search request", + }; + write!(f, "{}", msg) + } +} + +impl std::error::Error for MailDomainError {} + +pub fn mail_username_domain_from_equality_filter( + attr: &str, + value: &str, +) -> Result, MailDomainError> { + if attr != "mail" { + return Ok(None); + } + + let mut parts = value.split('@'); + let username = parts.next().unwrap(); + let Some(domain) = parts.next() else { + debug!("Not a valid username@domain email: {value}"); + return Err(MailDomainError::InvalidMail); + }; + + if parts.next().is_some() { + debug!("Too many parts in mail address"); + return Err(MailDomainError::InvalidMail); + } + + return Ok(Some((username.to_string(), domain.to_string()))); +} + +pub fn mail_username_domain_from_filters( + filters: &[LdapFilter], +) -> Result<(String, String), MailDomainError> { + for filter in filters { + match filter { + LdapFilter::Equality(attr, value) => { + if let Some(found) = mail_username_domain_from_equality_filter(attr, value)? { + return Ok(found); + } + } + _ => continue, + } + } + + debug!("No mail filter found"); + return Err(MailDomainError::NoMailFilter); +} + +pub fn mail_username_domain_from_filter( + filter: &LdapFilter, +) -> Result<(String, String), MailDomainError> { + match filter { + LdapFilter::And(filters) => mail_username_domain_from_filters(&filters), + LdapFilter::Or(filters) => mail_username_domain_from_filters(&filters), + LdapFilter::Equality(attr, value) => { + if let Some(domain) = mail_username_domain_from_equality_filter(attr, value)? { + Ok(domain) + } else { + debug!("No mail filter found"); + Err(MailDomainError::NoMailFilter) + } + } + _ => Err(MailDomainError::InvalidFilter), + } +} + +pub async fn search_by_email_attribute( + w: &mut FramedWrite, + mut sr: LdapSearchRequest, + msgid: i32, + ctrl: Vec, + config: Arc, +) -> Result<(), LdapError> { + debug!("unbound search by email attribute {:?}", sr); + // This is hardcoded to mail attr with AND filter for stalwart behavior + // We also support direct EQUALITY and OR filters just in case + let (_requested_username, requested_domain) = match mail_username_domain_from_filter(&sr.filter) + { + Ok(found) => { + info!( + "Found domain in search query {} with username {}", + found.1, found.0 + ); + found + } + Err(e) => { + let resp_msg = bind_operror(msgid, &e); + w.send(resp_msg).await.map_err(|err| { + error!("Unable to send response: {err}"); + LdapError::Transport + })?; + return Ok(()); + } + }; + + let Some(mapping) = config + .mapping + .iter() + .find(|x| x.from.to_lowercase() == requested_domain) + else { + debug!("No mapping found for domain {requested_domain}"); + // TODO: we should probably send an answer to the client here + return Err(LdapError::InvalidQuery); + }; + + let mut client = match BasicLdapClient::build(&mapping.backend).await { + Ok(c) => c, + Err(e) => { + // TODO: error code backend unavailable and more detailed message (timeout/connection refused) + error!("A client build error has occurred: {e:?}"); + let resp_msg = bind_operror(msgid, &"unable to bind"); + w.send(resp_msg).await.map_err(|err| { + error!("Unable to send response: {err}"); + LdapError::Transport + })?; + // Always bail. + return Ok(()); + } + }; + + let lbr = LdapBindRequest { + dn: mapping.user.to_string(), + cred: LdapBindCred::Simple(mapping.password.to_string()), + }; + + match client.bind(lbr, ctrl.clone()).await { + Ok((bind_resp, _ctrl)) => { + // Almost there, lets check the bind result. + let valid = bind_resp.res.code == LdapResultCode::Success; + + if !valid { + // TODO: client error + error!("Invalid backend credentials!"); + return Ok(()); + } + } + Err(e) => { + // TODO: client error + warn!("Failed to bind to the backend: {e:?}"); + return Ok(()); + } + } + + // Now edit the search base DN to match what the backend server expects + let mut dn = Dn::from_dn_str(&sr.base)?; + // Maybe there was no hostname to begin with, force to add it + dn.set_hostname(&mapping.to, true); + let dn = dn.to_dn_string(); + debug!("Rewrote anonymous search DN from {} to {}", sr.base, dn); + sr.base = dn; + + // Query the backend and rewrote matching dns with the mapping vhost + let (entries, result, ctrl) = match client.search(sr, ctrl).await { + Ok(data) => data, + Err(e) => { + error!("A client search error has occurred: {e:?}"); + let resp_msg = bind_operror(msgid, &"unable to search"); + w.send(resp_msg).await.map_err(|err| { + error!("Unable to send response: {err}"); + LdapError::Transport + })?; + + // Error sent, return with no state change. + return Ok(()); + } + }; + + for (mut entry, ctrl) in entries { + trace!("Search result from backend: {:?}", entry); + // TODO: maybe don't fail the whole request here??? + let mut dn = Dn::from_dn_str(&entry.dn)?; + dn.set_hostname(&mapping.from, false); + entry.dn = dn.to_dn_string(); + debug!("Search result: {:?}", entry); + + w.send(LdapMsg { + msgid, + op: LdapOp::SearchResultEntry(entry), + ctrl, + }) + .await + .map_err(|err| { + error!("Unable to send response: {err}"); + LdapError::Transport + })?; + } + + debug!("Search result done: {:?}", result); + w.send(LdapMsg { + msgid, + op: LdapOp::SearchResultDone(result), + ctrl, + }) + .await + .map_err(|err| { + error!("Unable to send response: {err}"); + LdapError::Transport + })?; + + Ok(()) +} + pub async fn search( w: &mut FramedWrite, search_request: SearchRequest<'_>, ) -> Result<(), LdapError> { + debug!("{:?}", search_request.sr); let SearchRequest { sr, msgid, @@ -42,6 +265,7 @@ pub async fn search( }; for (entry, ctrl) in entries { + debug!("Search result: {:?}", entry); w.send(LdapMsg { msgid, op: LdapOp::SearchResultEntry(entry), @@ -54,6 +278,7 @@ pub async fn search( })?; } + debug!("Search result done: {:?}", result); w.send(LdapMsg { msgid, op: LdapOp::SearchResultDone(result),