diff --git a/README.md b/README.md index 3960895..a5d019e 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,6 @@ 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) @@ -41,16 +35,10 @@ 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 @@ -73,11 +61,3 @@ 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/client.rs b/src/client.rs index f7488b3..870d622 100644 --- a/src/client.rs +++ b/src/client.rs @@ -33,12 +33,14 @@ impl BasicLdapClient { t } Ok(Err(err)) => { + // trace!(?addr, ?err, "error"); error!("error to {addr}: {err}"); - return Err(LdapError::ConnectError); + panic!(); } Err(_) => { warn!("timeout to {addr}"); - return Err(LdapError::Transport); + panic!(); + // continue; } }; unixstream.into() @@ -51,11 +53,12 @@ impl BasicLdapClient { Ok(Err(err)) => { // trace!(?addr, ?err, "error"); error!("error to {addr}: {err}"); - return Err(LdapError::ConnectError); + panic!(); } Err(_) => { warn!("timeout to {addr}"); - return Err(LdapError::Transport); + panic!(); + // continue; } }; tcpstream.into() diff --git a/src/config.rs b/src/config.rs index 988fc0f..4366047 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,6 +64,4 @@ pub struct Mapping { pub from: String, pub to: String, pub backend: String, - pub user: String, - pub password: String, } diff --git a/src/dn.rs b/src/dn.rs index 58cfaeb..d3793f3 100644 --- a/src/dn.rs +++ b/src/dn.rs @@ -24,10 +24,6 @@ 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 @@ -102,37 +98,3 @@ 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"); - } -} diff --git a/src/main.rs b/src/main.rs index 5726877..403e403 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,11 +63,8 @@ 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 { @@ -99,20 +96,42 @@ pub async fn client_process( ctrl, }, ) => { - // 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; + // 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, + } + } } - // We are still not logged in, but we don't abort the session - Some(ClientState::Unbound) + next_state } // Authenticated message handler. @@ -136,8 +155,6 @@ 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/bind.rs b/src/op/bind.rs index 51b5171..cfa9d65 100644 --- a/src/op/bind.rs +++ b/src/op/bind.rs @@ -9,9 +9,7 @@ use std::sync::Arc; use crate::{BasicLdapClient, ClientState, Config, Dn, LdapError}; -// 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 { +pub fn bind_operror(msgid: i32, msg: &str) -> LdapMsg { LdapMsg { msgid, op: LdapOp::BindResponse(LdapBindResponse { @@ -36,33 +34,6 @@ 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); @@ -81,7 +52,6 @@ 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); }; @@ -92,15 +62,14 @@ pub async fn bind( ); dn.set_hostname(&mapping.to, false); lbr.dn = dn.to_dn_string(); - let backend_dn = lbr.dn.clone(); + let 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:?}"); - // TODO: send more detailed error to the client (connection refused / timeout) - 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 @@ -128,7 +97,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 @@ -139,10 +108,10 @@ pub async fn bind( }; if valid { - info!("Successful bind for `{request_dn}` -> `{backend_dn}`"); + info!("Successful bind for {}", dn); Ok(Some(ClientState::Authenticated { request_dn, - backend_dn, + backend_dn: dn, client, })) } else { diff --git a/src/op/search.rs b/src/op/search.rs index 7237f47..f5934fc 100644 --- a/src/op/search.rs +++ b/src/op/search.rs @@ -5,10 +5,8 @@ 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, Config, Dn, LdapError}; +use crate::{BasicLdapClient, LdapError}; pub struct SearchRequest<'a> { pub sr: LdapSearchRequest, @@ -17,231 +15,10 @@ 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, @@ -253,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 @@ -265,7 +42,6 @@ pub async fn search( }; for (entry, ctrl) in entries { - debug!("Search result: {:?}", entry); w.send(LdapMsg { msgid, op: LdapOp::SearchResultEntry(entry), @@ -278,7 +54,6 @@ pub async fn search( })?; } - debug!("Search result done: {:?}", result); w.send(LdapMsg { msgid, op: LdapOp::SearchResultDone(result),