feat: Search by mail attribute (stalwart compatibility)

This commit is contained in:
selfhoster selfhoster 2026-08-28 12:03:23 +02:00
commit c746e594ca
4 changed files with 265 additions and 35 deletions

View file

@ -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.

View file

@ -64,4 +64,6 @@ pub struct Mapping {
pub from: String,
pub to: String,
pub backend: String,
pub user: String,
pub password: String,
}

View file

@ -63,8 +63,11 @@ pub async fn client_process<W: AsyncWrite + Unpin, R: AsyncRead + Unpin>(
// 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<W: AsyncWrite + Unpin, R: AsyncRead + Unpin>(
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<W: AsyncWrite + Unpin, R: AsyncRead + Unpin>(
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,

View file

@ -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<Option<(String, String)>, 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: AsyncWrite + Unpin>(
w: &mut FramedWrite<W, LdapCodec>,
mut sr: LdapSearchRequest,
msgid: i32,
ctrl: Vec<LdapControl>,
config: Arc<Config>,
) -> 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: AsyncWrite + Unpin>(
w: &mut FramedWrite<W, LdapCodec>,
search_request: SearchRequest<'_>,
) -> Result<(), LdapError> {
debug!("{:?}", search_request.sr);
let SearchRequest {
sr,
msgid,
@ -42,6 +265,7 @@ pub async fn search<W: AsyncWrite + Unpin>(
};
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<W: AsyncWrite + Unpin>(
})?;
}
debug!("Search result done: {:?}", result);
w.send(LdapMsg {
msgid,
op: LdapOp::SearchResultDone(result),