feat: Search by mail attribute (stalwart compatibility)
This commit is contained in:
parent
a30e9c3dd9
commit
c746e594ca
4 changed files with 265 additions and 35 deletions
20
README.md
20
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] No TLS setup ; use only in trusted networks
|
||||||
- [x] LDAP bind requests
|
- [x] LDAP bind requests
|
||||||
- [x] LDAP search 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
|
- [x] Configurable listening port
|
||||||
- [ ] Default fallback to `/etc/ldap-rp/config.toml`
|
- [ ] Default fallback to `/etc/ldap-rp/config.toml`
|
||||||
- [x] Unix Domain Socket support (incoming requests)
|
- [x] Unix Domain Socket support (incoming requests)
|
||||||
|
|
@ -35,10 +41,16 @@ to = "example.com"
|
||||||
# - start with anything else for a TCP connection
|
# - start with anything else for a TCP connection
|
||||||
# backend = "/run/lldap/example.com.sock"
|
# backend = "/run/lldap/example.com.sock"
|
||||||
backend = "127.0.0.1:4389"
|
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]]
|
[[mapping]]
|
||||||
from = "b.localhost"
|
from = "b.localhost"
|
||||||
to = "example.com"
|
to = "example.com"
|
||||||
backend = "127.0.0.1:5389"
|
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
|
~~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`
|
# 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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,6 @@ pub struct Mapping {
|
||||||
pub from: String,
|
pub from: String,
|
||||||
pub to: String,
|
pub to: String,
|
||||||
pub backend: String,
|
pub backend: String,
|
||||||
|
pub user: String,
|
||||||
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
src/main.rs
49
src/main.rs
|
|
@ -63,8 +63,11 @@ pub async fn client_process<W: AsyncWrite + Unpin, R: AsyncRead + Unpin>(
|
||||||
|
|
||||||
// Start to wait for incoming packets
|
// Start to wait for incoming packets
|
||||||
while let Ok(Some(Ok(protomsg))) = timeout(LDAP_CLIENT_IO_TIMEOUT, r.next()).await {
|
while let Ok(Some(Ok(protomsg))) = timeout(LDAP_CLIENT_IO_TIMEOUT, r.next()).await {
|
||||||
|
trace!("{:#?}", protomsg);
|
||||||
let next_state = match (&mut state, protomsg) {
|
let next_state = match (&mut state, protomsg) {
|
||||||
// Doesn't matter what state we are in, any bind will trigger this process.
|
// 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 {
|
LdapMsg {
|
||||||
|
|
@ -96,42 +99,20 @@ pub async fn client_process<W: AsyncWrite + Unpin, R: AsyncRead + Unpin>(
|
||||||
ctrl,
|
ctrl,
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
// We have to trigger a bind first in case we have a mapping.
|
// It's very tempting here to just say every email exists, and let stalwart perform
|
||||||
let lbr = LdapBindRequest {
|
// the bind to check. However, what about *receiving emails*? Do you really want
|
||||||
dn: "".to_string(),
|
// to pretend every mailbox exists here instead of letting stalwart know there
|
||||||
cred: LdapBindCred::Simple("".to_string()),
|
// is no such recipient? Do you????
|
||||||
};
|
if let Err(e) =
|
||||||
|
op::search::search_by_email_attribute(&mut w, sr, msgid, ctrl, config.clone())
|
||||||
let mut next_state =
|
.await
|
||||||
match op::bind::bind(&mut w, lbr, config.clone(), 0, Vec::default()).await {
|
{
|
||||||
Ok(ns) => ns,
|
debug!("Search unsuccessful: {e:?}");
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
|
|
||||||
match &mut next_state {
|
|
||||||
Some(ClientState::Unbound) | None => {
|
|
||||||
error!("Invalid state, bind did not return an authenticated state!");
|
|
||||||
break;
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next_state
|
// We are still not logged in, but we don't abort the session
|
||||||
|
Some(ClientState::Unbound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated message handler.
|
// Authenticated message handler.
|
||||||
|
|
@ -155,6 +136,8 @@ pub async fn client_process<W: AsyncWrite + Unpin, R: AsyncRead + Unpin>(
|
||||||
client,
|
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 {
|
match op::search::search(&mut w, search_req).await {
|
||||||
Ok(()) => None,
|
Ok(()) => None,
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
|
|
|
||||||
227
src/op/search.rs
227
src/op/search.rs
|
|
@ -5,8 +5,10 @@ use ldap3_proto::proto::*;
|
||||||
use tokio::io::AsyncWrite;
|
use tokio::io::AsyncWrite;
|
||||||
use tokio_util::codec::FramedWrite;
|
use tokio_util::codec::FramedWrite;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::op::bind::bind_operror;
|
use crate::op::bind::bind_operror;
|
||||||
use crate::{BasicLdapClient, LdapError};
|
use crate::{BasicLdapClient, Config, Dn, LdapError};
|
||||||
|
|
||||||
pub struct SearchRequest<'a> {
|
pub struct SearchRequest<'a> {
|
||||||
pub sr: LdapSearchRequest,
|
pub sr: LdapSearchRequest,
|
||||||
|
|
@ -15,10 +17,231 @@ pub struct SearchRequest<'a> {
|
||||||
pub client: &'a mut BasicLdapClient,
|
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>(
|
pub async fn search<W: AsyncWrite + Unpin>(
|
||||||
w: &mut FramedWrite<W, LdapCodec>,
|
w: &mut FramedWrite<W, LdapCodec>,
|
||||||
search_request: SearchRequest<'_>,
|
search_request: SearchRequest<'_>,
|
||||||
) -> Result<(), LdapError> {
|
) -> Result<(), LdapError> {
|
||||||
|
debug!("{:?}", search_request.sr);
|
||||||
let SearchRequest {
|
let SearchRequest {
|
||||||
sr,
|
sr,
|
||||||
msgid,
|
msgid,
|
||||||
|
|
@ -42,6 +265,7 @@ pub async fn search<W: AsyncWrite + Unpin>(
|
||||||
};
|
};
|
||||||
|
|
||||||
for (entry, ctrl) in entries {
|
for (entry, ctrl) in entries {
|
||||||
|
debug!("Search result: {:?}", entry);
|
||||||
w.send(LdapMsg {
|
w.send(LdapMsg {
|
||||||
msgid,
|
msgid,
|
||||||
op: LdapOp::SearchResultEntry(entry),
|
op: LdapOp::SearchResultEntry(entry),
|
||||||
|
|
@ -54,6 +278,7 @@ pub async fn search<W: AsyncWrite + Unpin>(
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug!("Search result done: {:?}", result);
|
||||||
w.send(LdapMsg {
|
w.send(LdapMsg {
|
||||||
msgid,
|
msgid,
|
||||||
op: LdapOp::SearchResultDone(result),
|
op: LdapOp::SearchResultDone(result),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue