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),