Compare commits

..
7 changed files with 345 additions and 49 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

@ -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()

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

@ -24,6 +24,10 @@ impl Dn {
pub fn from_dn_str(input: &str) -> Result<Self, LdapError> {
let mut keys: IndexMap<String, Vec<String>> = 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");
}
}

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

@ -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 {
@ -34,6 +36,33 @@ pub async fn bind<W: AsyncWrite + Unpin>(
) -> Result<Option<ClientState>, 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 +81,7 @@ pub async fn bind<W: AsyncWrite + Unpin>(
.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);
};
@ -62,14 +92,15 @@ pub async fn bind<W: AsyncWrite + Unpin>(
);
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 {
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
@ -97,7 +128,7 @@ pub async fn bind<W: AsyncWrite + Unpin>(
}
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
@ -108,10 +139,10 @@ pub async fn bind<W: AsyncWrite + Unpin>(
};
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 {

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,22 +17,181 @@ pub struct SearchRequest<'a> {
pub client: &'a mut BasicLdapClient,
}
pub async fn search<W: AsyncWrite + Unpin>(
w: &mut FramedWrite<W, LdapCodec>,
search_request: SearchRequest<'_>,
) -> Result<(), LdapError> {
let SearchRequest {
sr,
msgid,
ctrl,
client,
} = search_request;
#[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");
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
@ -41,7 +202,14 @@ pub async fn search<W: AsyncWrite + Unpin>(
}
};
for (entry, ctrl) in entries {
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),
@ -54,6 +222,63 @@ pub async fn search<W: AsyncWrite + Unpin>(
})?;
}
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,
ctrl,
client,
} = search_request;
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 (entry, ctrl) in entries {
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),