diff --git a/Cargo.toml b/Cargo.toml index aa0f7a8..29fc99a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,11 @@ tokio = { version = "1.53.1", features = ["net", "rt", "macros", "time", "io-uti tokio-listener = { version = "0.5.2", features = ["serde"] } tokio-util = "0.7.19" toml = "1.1.4" + +[lints.clippy] +suspicious = "deny" +complexity = "deny" +pedantic = "deny" +perf = "deny" +style = "deny" +# cargo = "deny" diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..cc5e274 --- /dev/null +++ b/config.toml @@ -0,0 +1,16 @@ +# listen = "/tmp/ldap.sock" +listen = "127.0.0.1:3389" +[[mapping]] +from = "a.localhost" +to = "example.com" +# backend = "127.0.0.1:4389" +backend = "/tmp/lldap-4.sock" +user = "cn=stalwart,ou=people,dc=example,dc=com" +password = "adminadmin" +[[mapping]] +from = "b.localhost" +to = "example.com" +# backend = "127.0.0.1:5389" +backend = "/tmp/lldap-5.sock" +user = "cn=stalwart,ou=people,dc=example,dc=com" +password = "adminadmin" diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..5235f8e --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,343 @@ +use crate::prelude::*; + +#[derive(Debug)] +pub struct BackendError { + pub backend: BackendInfo, + pub kind: BackendErrorKind, +} + +#[derive(Debug)] +pub enum BackendErrorKind { + /// connection failed to the backend + ConnectionRefused, + /// timeout + Timeout, + /// Invalid credentials in ldap-rp config (when binding to the backend + /// for search). This is not triggered when using user-supplied credentials + /// when the user manually performs a bind. + InvalidCredentials, + /// Invalid message ID returned by the backend + InvalidMessageID(i32, i32), + /// Something unexpected was returned by the server + InvalidProtocolState, + /// Connection to the backend server was closed + ConnectionClosed, + /// Other error sending receiving data to the backend + IOError(std::io::Error), +} + +impl std::fmt::Display for BackendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.kind { + BackendErrorKind::ConnectionRefused => { + write!(f, "Failed to connect to {}", self.backend) + } + BackendErrorKind::Timeout => write!(f, "Timeout to {}", self.backend), + BackendErrorKind::InvalidCredentials => write!( + f, + "Invalid credentials when connecting to {}. This is an error in ldap-rp config and not your fault.", + self.backend + ), + BackendErrorKind::InvalidProtocolState => write!( + f, + "Invalid protocol state in backend server {}", + self.backend + ), + BackendErrorKind::InvalidMessageID(expected, found) => write!( + f, + "Invalid message ID `{found}` returned by the backend server {}, expected `{expected}`", + self.backend + ), + BackendErrorKind::ConnectionClosed => write!( + f, + "Connection to the backend server {} has been closed", + self.backend + ), + BackendErrorKind::IOError(e) => write!( + f, + "Uncategorized IO error during send/recv to backend {}: {:?}", + self.backend, e + ), + } + } +} + +impl ReturnError for BackendError { + fn code(&self) -> LdapResultCode { + match &self.kind { + BackendErrorKind::ConnectionRefused => LdapResultCode::Unavailable, + BackendErrorKind::Timeout => LdapResultCode::Busy, + BackendErrorKind::InvalidCredentials => LdapResultCode::InvalidCredentials, + BackendErrorKind::InvalidProtocolState | BackendErrorKind::InvalidMessageID(_, _) => { + LdapResultCode::ProtocolError + } + BackendErrorKind::ConnectionClosed => LdapResultCode::OperationsError, + BackendErrorKind::IOError(_) => LdapResultCode::Other, + } + } + + fn message(&self) -> String { + self.to_string() + } +} + +/// Basic metadata for a requested backend. +#[derive(Clone, Debug)] +pub struct BackendInfo { + pub external_domain: String, + // Unused for now but may prove handy in the future + #[expect(dead_code)] + pub internal_domain: String, + pub addr: String, +} + +impl std::fmt::Display for BackendInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.external_domain, self.addr) + } +} + +impl From for BackendInfo { + fn from(m: crate::config::Mapping) -> Self { + Self { + external_domain: m.from, + internal_domain: m.to, + addr: m.backend, + } + } +} + +impl BackendInfo { + pub async fn connect(&self) -> Result { + // If addr is a relative or absolute path, consider it's a socket + if self.addr.starts_with('.') || self.addr.starts_with('/') { + match timeout(Duration::from_secs(1), UnixStream::connect(&self.addr)).await { + Ok(Ok(stream)) => { + trace!("connection established to {self}"); + Ok(stream.into()) + } + Ok(Err(err)) => { + error!("Connection failed to {self}: {err}"); + Err(self.err_connection_refused()) + } + Err(_) => { + warn!("timeout to {self}"); + Err(self.err_timeout()) + } + } + } else { + match timeout(Duration::from_secs(1), TcpStream::connect(&self.addr)).await { + Ok(Ok(stream)) => { + trace!("connection established to {self}"); + Ok(stream.into()) + } + Ok(Err(err)) => { + error!("Connection failed to {self}: {err}"); + Err(self.err_connection_refused()) + } + Err(_) => { + warn!("timeout to {self}"); + Err(self.err_timeout()) + } + } + } + } + + pub fn err_connection_refused(&self) -> BackendError { + BackendError { + backend: self.clone(), + kind: BackendErrorKind::ConnectionRefused, + } + } + + pub fn err_timeout(&self) -> BackendError { + BackendError { + backend: self.clone(), + kind: BackendErrorKind::Timeout, + } + } + + pub fn err_invalid_credentials(&self) -> BackendError { + BackendError { + backend: self.clone(), + kind: BackendErrorKind::InvalidCredentials, + } + } + + pub fn err_invalid_protocol_state(&self) -> BackendError { + BackendError { + backend: self.clone(), + kind: BackendErrorKind::InvalidProtocolState, + } + } + + pub fn err_invalid_message_id(&self, expected: i32, found: i32) -> BackendError { + BackendError { + backend: self.clone(), + kind: BackendErrorKind::InvalidMessageID(expected, found), + } + } + + pub fn err_connection_closed(&self) -> BackendError { + BackendError { + backend: self.clone(), + kind: BackendErrorKind::ConnectionClosed, + } + } + + pub fn err_io_error(&self, e: std::io::Error) -> BackendError { + BackendError { + backend: self.clone(), + kind: BackendErrorKind::IOError(e), + } + } +} + +pub struct BackendClient { + r: FramedRead, LdapCodec>, + w: FramedWrite, LdapCodec>, + msg_counter: i32, + pub backend: BackendInfo, +} + +impl BackendClient { + fn next_msgid(&mut self) -> i32 { + self.msg_counter += 1; + self.msg_counter + } + + pub async fn send(&mut self, msg: LdapMsg) -> Result<(), BackendError> { + self.w.send(msg).await.map_err(|e| { + error!("Sending error to backend {}: {:?}", self.backend, e); + self.backend.err_io_error(e) + }) + } + + pub async fn build(backend: BackendInfo) -> Result { + let stream = backend.connect().await?; + + let (r, w) = tokio::io::split(stream); + let w = FramedWrite::new(w, LdapCodec::new(None, None)); + let r = FramedRead::new(r, LdapCodec::new(None, None)); + + Ok(Self { + r, + w, + msg_counter: 0, + backend, + }) + } + + pub async fn bind( + &mut self, + lbr: LdapBindRequest, + ctrl: Vec, + ) -> Result<(LdapBindResponse, Vec), BackendError> { + let ck_msgid = self.next_msgid(); + + let msg = LdapMsg { + msgid: ck_msgid, + op: LdapOp::BindRequest(lbr), + ctrl, + }; + self.send(msg).await?; + + let Some(msg) = self.r.next().await else { + warn!("connection closed"); + return Err(self.backend.err_connection_closed()); + }; + + let msg = match msg { + Ok(msg) => msg, + Err(e) => { + error!("unable to receive from ldap server: {e}"); + return Err(self.backend.err_io_error(e)); + } + }; + + if let LdapMsg { + msgid, + op: LdapOp::BindResponse(bind_resp), + ctrl, + } = msg + { + if msgid == ck_msgid { + Ok((bind_resp, ctrl)) + } else { + error!("invalid msgid, sequence error."); + Err(self.backend.err_invalid_message_id(ck_msgid, msgid)) + } + } else { + trace!("{msg:?}"); + warn!("unexpected response from backend to bind command"); + Err(self.backend.err_invalid_protocol_state()) + } + } + + pub async fn search( + &mut self, + sr: LdapSearchRequest, + ctrl: Vec, + ) -> Result< + ( + Vec<(LdapSearchResultEntry, Vec)>, + LdapResult, + Vec, + ), + BackendError, + > { + let ck_msgid = self.next_msgid(); + + let msg = LdapMsg { + msgid: ck_msgid, + op: LdapOp::SearchRequest(sr), + ctrl, + }; + + self.send(msg).await?; + + let mut entries = Vec::new(); + loop { + let Some(msg) = self.r.next().await else { + error!("connection closed"); + return Err(self.backend.err_connection_closed()); + }; + + let msg = msg.map_err(|e| { + error!("unable to receive from ldap server: {e}"); + self.backend.err_io_error(e) + })?; + + match msg { + // This terminates the iteration of entries. + LdapMsg { + msgid, + op: LdapOp::SearchResultDone(search_res), + ctrl, + } => { + if msgid == ck_msgid { + return Ok((entries, search_res, ctrl)); + } + error!("invalid msgid, sequence error."); + return Err(self.backend.err_invalid_message_id(ck_msgid, msgid)); + } + LdapMsg { + msgid, + op: LdapOp::SearchResultEntry(search_entry), + ctrl, + } => { + if msgid == ck_msgid { + entries.push((search_entry, ctrl)); + } else { + error!("invalid msgid, sequence error."); + return Err(self.backend.err_invalid_message_id(ck_msgid, msgid)); + } + } + _ => { + trace!("invalid message {msg:?}"); + return Err(self.backend.err_invalid_protocol_state()); + } + } + } + } +} diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index f7488b3..0000000 --- a/src/client.rs +++ /dev/null @@ -1,196 +0,0 @@ -use futures_util::sink::SinkExt; -use futures_util::stream::StreamExt; -use ldap3_proto::LdapCodec; -use ldap3_proto::control::LdapControl; -use ldap3_proto::proto::*; -use tokio::net::{TcpStream, UnixStream}; -use tokio::time::timeout; -use tokio_util::codec::{FramedRead, FramedWrite}; - -use std::time::Duration; - -use crate::{AbstractStream, CR, CW, LdapError}; - -pub struct BasicLdapClient { - r: FramedRead, - w: FramedWrite, - msg_counter: i32, -} - -impl BasicLdapClient { - fn next_msgid(&mut self) -> i32 { - self.msg_counter += 1; - self.msg_counter - } - - pub async fn build(addr: &str) -> Result { - // If addr is a relative or absolute path, consider it's a socket - let stream: AbstractStream = if addr.starts_with('.') || addr.starts_with('/') { - let unixstream = match timeout(Duration::from_secs(1), UnixStream::connect(addr)).await - { - Ok(Ok(t)) => { - trace!("connection established to {addr}"); - t - } - Ok(Err(err)) => { - error!("error to {addr}: {err}"); - return Err(LdapError::ConnectError); - } - Err(_) => { - warn!("timeout to {addr}"); - return Err(LdapError::Transport); - } - }; - unixstream.into() - } else { - let tcpstream = match timeout(Duration::from_secs(1), TcpStream::connect(addr)).await { - Ok(Ok(t)) => { - trace!("connection established to {addr}"); - t - } - Ok(Err(err)) => { - // trace!(?addr, ?err, "error"); - error!("error to {addr}: {err}"); - return Err(LdapError::ConnectError); - } - Err(_) => { - warn!("timeout to {addr}"); - return Err(LdapError::Transport); - } - }; - tcpstream.into() - }; - - let (r, w) = tokio::io::split(stream); - - let w = FramedWrite::new(w, LdapCodec::new(None, None)); - let r = FramedRead::new(r, LdapCodec::new(None, None)); - - Ok(Self { - r, - w, - msg_counter: 0, - }) - } - - pub async fn bind( - &mut self, - lbr: LdapBindRequest, - ctrl: Vec, - ) -> Result<(LdapBindResponse, Vec), LdapError> { - let ck_msgid = self.next_msgid(); - - let msg = LdapMsg { - msgid: ck_msgid, - op: LdapOp::BindRequest(lbr), - ctrl, - }; - - match self.w.send(msg).await { - Ok(_) => {} - Err(err) => { - error!("unable to transmit to ldap server: {err}"); - return Err(LdapError::Transport); - } - }; - - match self.r.next().await { - Some(Ok(LdapMsg { - msgid, - op: LdapOp::BindResponse(bind_resp), - ctrl, - })) => { - if msgid == ck_msgid { - Ok((bind_resp, ctrl)) - } else { - error!("invalid msgid, sequence error."); - Err(LdapError::InvalidProtocolState) - } - } - Some(Ok(msg)) => { - trace!("{:?}", msg); - Err(LdapError::InvalidProtocolState) - } - Some(Err(e)) => { - error!("unable to receive from ldap server: {e}"); - Err(LdapError::Transport) - } - None => { - error!("connection closed"); - Err(LdapError::Transport) - } - } - } - - pub async fn search( - &mut self, - sr: LdapSearchRequest, - ctrl: Vec, - ) -> Result< - ( - Vec<(LdapSearchResultEntry, Vec)>, - LdapResult, - Vec, - ), - LdapError, - > { - let ck_msgid = self.next_msgid(); - - let msg = LdapMsg { - msgid: ck_msgid, - op: LdapOp::SearchRequest(sr), - ctrl, - }; - - match self.w.send(msg).await { - Ok(_) => {} - Err(err) => { - error!("unable to transmit to ldap server: {err}"); - return Err(LdapError::Transport); - } - }; - - let mut entries = Vec::new(); - loop { - match self.r.next().await { - // This terminates the iteration of entries. - Some(Ok(LdapMsg { - msgid, - op: LdapOp::SearchResultDone(search_res), - ctrl, - })) => { - if msgid == ck_msgid { - break Ok((entries, search_res, ctrl)); - } else { - error!("invalid msgid, sequence error."); - break Err(LdapError::InvalidProtocolState); - } - } - Some(Ok(LdapMsg { - msgid, - op: LdapOp::SearchResultEntry(search_entry), - ctrl, - })) => { - if msgid == ck_msgid { - entries.push((search_entry, ctrl)) - } else { - error!("invalid msgid, sequence error."); - break Err(LdapError::InvalidProtocolState); - } - } - Some(Ok(msg)) => { - trace!("{:?}", msg); - break Err(LdapError::InvalidProtocolState); - } - Some(Err(e)) => { - error!("unable to receive from ldap server: {e}"); - break Err(LdapError::Transport); - } - None => { - error!("connection closed"); - break Err(LdapError::Transport); - } - } - } - } -} diff --git a/src/config.rs b/src/config.rs index 988fc0f..1d09a84 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,22 @@ use tokio_listener::{Listener, ListenerAddress, SystemOptions, UserOptions}; use std::net::{Ipv6Addr, SocketAddrV6}; use std::path::Path; -#[derive(Clone, Debug, Deserialize)] +use crate::prelude::*; + +#[derive(Clone, Debug)] +pub struct DomainNotFound(pub String); + +impl ReturnError for DomainNotFound { + fn code(&self) -> LdapResultCode { + LdapResultCode::NoSuchObject + } + + fn message(&self) -> String { + format!("No such LDAP backend domain: {}", self.0) + } +} + +#[derive(Clone, Debug, Default, Deserialize)] pub struct Config { pub listen: Option, pub mapping: Vec, @@ -17,6 +32,14 @@ impl Config { Ok(toml::from_slice(&content)?) } + pub fn get_mapping(&self, requested_domain: &str) -> Result { + self.mapping + .iter() + .find(|x| x.from.to_lowercase() == requested_domain) + .cloned() + .ok_or(DomainNotFound(requested_domain.to_string())) + } + pub async fn listener(&self) -> anyhow::Result { let listener_addr = if let Some(listen) = &self.listen { // For now we only support ADDR:PORT and Unix domain sockets @@ -38,7 +61,7 @@ impl Config { error!( "To use unix domain sockets, use relative or absolute paths, eg. `./ldap.sock` or `/var/run/ldap.sock`" ); - bail!("Invalid listen option, currently parsed to: {:?}", listen); + bail!("Invalid listen option, currently parsed to: {listen:?}"); } } } else { @@ -47,8 +70,8 @@ impl Config { &ListenerAddress::Tcp(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 389, 0, 0).into()) }; - let system_options: SystemOptions = Default::default(); - let mut user_options: UserOptions = Default::default(); + let system_options = SystemOptions::default(); + let mut user_options = UserOptions::default(); // If there's a left over socket, delete it instead of erroring. // If another daemon is still listening over there, it will no longer // receive connections. @@ -59,7 +82,7 @@ impl Config { } } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub struct Mapping { pub from: String, pub to: String, diff --git a/src/dn.rs b/src/dn.rs index 58cfaeb..98df097 100644 --- a/src/dn.rs +++ b/src/dn.rs @@ -1,7 +1,20 @@ use indexmap::IndexMap; use ldap3::dn_escape; -use crate::LdapError; +use crate::prelude::*; + +#[derive(Clone, Debug)] +pub struct InvalidDnError(pub String); + +impl ReturnError for InvalidDnError { + fn code(&self) -> LdapResultCode { + LdapResultCode::InvalidDNSyntax + } + + fn message(&self) -> String { + format!("Invalid dn: {}", self.0) + } +} /// A Dn is a key-value mapping which can contain the same key several times. /// @@ -21,10 +34,10 @@ impl Dn { /// /// However, if we find a really funny request such as `dc=foo=bar`, then /// we return an error to the client. - pub fn from_dn_str(input: &str) -> Result { + pub fn from_dn_str(input: &str) -> Result { let mut keys: IndexMap> = IndexMap::new(); - if input == "" { + if input.is_empty() { return Ok(Self { keys }); } @@ -34,7 +47,7 @@ impl Dn { if query_parts.clone().count() != 2 { // Bad request log::debug!("Invalid query DN: {input}"); - return Err(LdapError::InvalidQuery); + return Err(InvalidDnError(input.to_string())); } let key = query_parts.next().unwrap(); diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d44ab78 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,6 @@ +use ldap3_proto::LdapResultCode; + +pub trait ReturnError { + fn code(&self) -> LdapResultCode; + fn message(&self) -> String; +} diff --git a/src/filter/mail.rs b/src/filter/mail.rs new file mode 100644 index 0000000..b03591b --- /dev/null +++ b/src/filter/mail.rs @@ -0,0 +1,241 @@ +use ldap3_proto::{LdapFilter, LdapResultCode}; + +use std::fmt; + +use crate::error::ReturnError; + +#[derive(Clone, Debug)] +pub struct MailFilter { + pub complete: String, + #[cfg_attr(not(test), expect(unused))] + pub username: String, + pub domain: String, +} + +impl fmt::Display for MailFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.complete) + } +} + +impl MailFilter { + /// Tries to parse a string into an email address. + /// + /// This method is not really RFC-compliant because it merely splits by `@`, + /// but is good enough for what we do. + pub fn new(value: &str) -> Result { + let original = value; + // Here we normalize the mail attribute to lowercase to prevent + // useless mismatches. + // TODO: investigate if that's supposed to be a problem for anyone? + let value = value.to_lowercase(); + if value != original { + debug!("Normalized search email from {original} to {value}"); + } + + 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); + } + + Ok(Self { + complete: value.clone(), + username: username.to_string(), + domain: domain.to_string(), + }) + } + + /// Extract any mail filter from an LDAP search filter + /// which may contain other criteria, which we overall don't care about + /// at the moment. + pub fn from_search_filter(filter: &LdapFilter) -> Result { + let res = match filter { + LdapFilter::And(filters) | LdapFilter::Or(filters) => { + Self::from_multiple_filters(filters) + } + LdapFilter::Equality(attr, value) => { + if let Some(domain) = Self::from_equality_filter(attr, value)? { + Ok(domain) + } else { + Err(MailDomainError::NoMailFilter) + } + } + _ => Err(MailDomainError::InvalidFilter), + }; + + match &res { + Ok(mail) => debug!("Found email in search filter: {mail}"), + Err(e) => debug!("Not found email in search filter: {}", e.message()), + } + + res + } + + /// Extract any mail filter from a bunch of LDAP filters. + /// + /// Any filter that is not an equality check is discarded. + fn from_multiple_filters(filters: &[LdapFilter]) -> Result { + for filter in filters { + if let LdapFilter::Equality(attr, value) = filter { + // Here if we receive None, it means the filter was not checking for the + // `mail` attr so we continue iterating. + if let Some(found) = Self::from_equality_filter(attr, value)? { + return Ok(found); + } + } + } + + Err(MailDomainError::NoMailFilter) + } + + /// Extract any mail filter from an LDAP search equality filter extracted + /// from a global search filter. + fn from_equality_filter(attr: &str, value: &str) -> Result, MailDomainError> { + if attr != "mail" { + return Ok(None); + } + + let mail = Self::new(value)?; + Ok(Some(mail)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +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 fmt::Display for MailDomainError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 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 ReturnError for MailDomainError { + fn code(&self) -> LdapResultCode { + match self { + Self::InvalidMail => LdapResultCode::InvalidAttributeSyntax, + Self::NoMailFilter => LdapResultCode::InappropriateMatching, + Self::InvalidFilter => LdapResultCode::UnwillingToPerform, + } + } + + fn message(&self) -> String { + self.to_string() + } +} + +#[cfg(test)] +mod tests { + use crate::filter::{search_filter_and, search_filter_eq}; + + use super::*; + + #[test] + fn no_mail_filter() { + let filter = search_filter_and(&[ + search_filter_eq("uid", "a"), + search_filter_eq("objectClass", "inetOrgPerson"), + ]); + + let mail_filter = MailFilter::from_search_filter(&filter); + println!("{:?}", mail_filter); + let e = mail_filter.unwrap_err(); + assert_eq!(e, MailDomainError::NoMailFilter); + } + + #[test] + fn invalid_filter() { + let filter = LdapFilter::Approx("mail".to_string(), "a@a.localhost".to_string()); + let mail_filter = MailFilter::from_search_filter(&filter); + println!("{:?}", mail_filter); + let e = mail_filter.unwrap_err(); + assert_eq!(e, MailDomainError::InvalidFilter); + } + + #[test] + fn invalid_mail_no_domain() { + let filter = search_filter_eq("mail", "a"); + let mail_filter = MailFilter::from_search_filter(&filter); + println!("{:?}", mail_filter); + let e = mail_filter.unwrap_err(); + assert_eq!(e, MailDomainError::InvalidMail); + } + + #[test] + fn invalid_mail_too_many_parts() { + let filter = search_filter_eq("mail", "a@a.localhost@a.localhost"); + let mail_filter = MailFilter::from_search_filter(&filter); + println!("{:?}", mail_filter); + let e = mail_filter.unwrap_err(); + assert_eq!(e, MailDomainError::InvalidMail); + } + + #[test] + fn valid_basic() { + let filter = search_filter_eq("mail", "a@a.localhost"); + let mail_filter = MailFilter::from_search_filter(&filter); + println!("{:?}", mail_filter); + let mail = mail_filter.unwrap(); + assert_eq!(mail.complete, "a@a.localhost"); + assert_eq!(mail.username, "a"); + assert_eq!(mail.domain, "a.localhost"); + } + + #[test] + fn valid_basic_normalization() { + let filter = search_filter_eq("mail", "A@A.localhost"); + let mail_filter = MailFilter::from_search_filter(&filter); + println!("{:?}", mail_filter); + let mail = mail_filter.unwrap(); + assert_eq!(mail.complete, "a@a.localhost"); + assert_eq!(mail.username, "a"); + assert_eq!(mail.domain, "a.localhost"); + } + + #[test] + fn stalwart_default() { + // (&(objectClass=inetOrgPerson)(mail=?)) + let filter = search_filter_and(&[ + search_filter_eq("mail", "a@a.localhost"), + search_filter_eq("objectClass", "inetOrgPerson"), + ]); + + let mail_filter = MailFilter::from_search_filter(&filter); + println!("{:?}", mail_filter); + let mail = mail_filter.unwrap(); + assert_eq!(mail.complete, "a@a.localhost"); + assert_eq!(mail.username, "a"); + assert_eq!(mail.domain, "a.localhost"); + } + + // fn stalwart_lldap_example() { + // // &(|(objectClass=person)(member=cn=mail,ou=groups,dc=example,dc=org))(uid=?)) + // let filter = search_filter_and(&[ + // search_filter_eq("uid", "a"), + // search_filter_or(&[ + // search_filter_eq("objectClass", "person"), + // search_filter_eq("member", "cn=mail,ou=groups,dc=a,dc=localhost"), + // ]), + // ]); + // } +} diff --git a/src/filter/mod.rs b/src/filter/mod.rs new file mode 100644 index 0000000..0e8dd6c --- /dev/null +++ b/src/filter/mod.rs @@ -0,0 +1,19 @@ +#[cfg(test)] +use ldap3_proto::LdapFilter; + +pub mod mail; + +#[cfg(test)] +pub fn search_filter_eq(attr: &str, value: &str) -> LdapFilter { + LdapFilter::Equality(attr.to_string(), value.to_string()) +} + +#[cfg(test)] +pub fn search_filter_and(filters: &[LdapFilter]) -> LdapFilter { + LdapFilter::And(filters.to_vec()) +} + +#[cfg(test)] +pub fn search_filter_or(filters: &[LdapFilter]) -> LdapFilter { + LdapFilter::Or(filters.to_vec()) +} diff --git a/src/handler/client_process.rs b/src/handler/client_process.rs new file mode 100644 index 0000000..3d24e38 --- /dev/null +++ b/src/handler/client_process.rs @@ -0,0 +1,184 @@ +use crate::LDAP_CLIENT_IO_TIMEOUT; +use crate::prelude::*; + +use crate::backend::{BackendClient, BackendError}; + +/// Fatal error that aborts the client connection. +#[derive(Debug)] +pub enum UnrecoverableError { + // This variant contains more info about the failed backend for detailed logging + // instead of random "connection closed" error messages. + Backend(BackendError), + ClientClosed, + ClientIO(std::io::Error), + ClientTimeout, +} + +impl std::fmt::Display for UnrecoverableError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Backend(e) => write!(f, "Backend error: {e}"), + Self::ClientClosed => write!(f, "Client closed the connection"), + Self::ClientIO(e) => write!(f, "Client IO error: {e:?}"), + Self::ClientTimeout => write!(f, "Client timeout"), + } + } +} + +impl From for UnrecoverableError { + fn from(e: BackendError) -> Self { + Self::Backend(e) + } +} + +pub struct ClientConnection { + r: FramedRead, LdapCodec>, + w: FramedWrite, LdapCodec>, + pub config: Arc, +} + +// We allow the large enum to exist as we always do a mem swap from unbound to authenticated, so +// the memory layout penalty doesn't apply. +#[allow(clippy::large_enum_variant)] +pub enum ClientState { + Unbound, + Authenticated(AuthenticatedClient), +} + +impl ClientState { + /// Currently bound DN. + /// + /// When not bound yet, returns an empty string. + /// When bound, return the DN requested to the reverse proxy (before + /// mapping to the backend DN). + pub fn bound_dn(&self) -> &str { + match self { + Self::Unbound => "", + Self::Authenticated(c) => &c.request_dn, + } + } +} + +pub struct AuthenticatedClient { + // Isn't this used for whomai????? + pub request_dn: String, + #[expect(unused)] + pub backend_dn: String, + + #[expect(unused)] + pub client: BackendClient, +} + +impl ClientConnection { + pub fn new(stream: Connection, config: Arc) -> Self { + info!("Received new connection"); + let stream: AbstractStream = stream.into(); + let (r, w) = tokio::io::split(stream); + let r = FramedRead::new(r, LdapCodec::new(None, None)); + let w = FramedWrite::new(w, LdapCodec::new(None, None)); + Self { r, w, config } + } + + pub async fn send(&mut self, msg: LdapMsg) -> Result<(), UnrecoverableError> { + self.w.send(msg).await.map_err(|e| { + warn!("Sending to client failed: {e:?}"); + UnrecoverableError::ClientIO(e) + })?; + + Ok(()) + } + + pub async fn event_loop(&mut self) { + debug!("Starting client connection event loop"); + + // Start in unbound state + let mut state = ClientState::Unbound; + + // Handlers return Result>: + // - None means we close the connection because it's finished + // - Some(state) changes the state machine + // - Err(e) is some IO error to the client or backend + // that means we close the connection + loop { + match self.process_next_message(state).await { + Ok(Some(next_state)) => state = next_state, + Ok(None) => { + debug!("Connection finished succesfully"); + return; + } + Err(e) => { + debug!("Unrecoverable connection error: {e}"); + return; + } + } + } + } + + pub async fn process_next_message( + &mut self, + state: ClientState, + ) -> Result, UnrecoverableError> { + // Check for timeout + let Ok(msg) = timeout(LDAP_CLIENT_IO_TIMEOUT, self.r.next()).await else { + return Err(UnrecoverableError::ClientTimeout); + }; + + // Check for closed client connection + let Some(msg) = msg else { + return Err(UnrecoverableError::ClientClosed); + }; + + // Check for malformed client message + let protomsg = match msg { + Ok(protomsg) => protomsg, + Err(e) => { + warn!("Error parsing message from client: {e:?}"); + // Return the same previous state + return Ok(Some(state)); + } + }; + + // Check whether the completed operation aborts the client connection + match protomsg { + // Disconnect + LdapMsg { + msgid: _, + op: LdapOp::UnbindRequest, + ctrl: _, + } => Ok(None), + // Extended Requests - Generally whoami. + LdapMsg { + msgid, + op: LdapOp::ExtendedRequest(ler), + ctrl: _, + } => op_ext(self, ler, msgid, state.bound_dn()).await, + LdapMsg { + msgid, + op: LdapOp::BindRequest(lbr), + ctrl, + } => op_bind(self, lbr, msgid, ctrl).await, + LdapMsg { + msgid, + op: LdapOp::SearchRequest(sr), + 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???? + // TODO: in the future we might want to be able to list accounts by binding + // to multiple backends at once. + op_search_by_email_attribute(self, sr, msgid, ctrl, self.config.clone()).await?; + + // Search has no effect on the login status + // TODO: should it abort the connection? + Ok(Some(state)) + } + // Unsupported message + _ => { + error!("Unsupported unbound client message: {protomsg:?}"); + Ok(None) + } + } + } +} diff --git a/src/handler/mod.rs b/src/handler/mod.rs new file mode 100644 index 0000000..c079e43 --- /dev/null +++ b/src/handler/mod.rs @@ -0,0 +1,3 @@ +// pub mod bound; +pub mod client_process; +// pub mod unbound; diff --git a/src/main.rs b/src/main.rs index 5726877..d544f03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,33 +2,22 @@ extern crate log; use clap::Parser; -use futures_util::StreamExt; -use ldap3_proto::LdapCodec; -use ldap3_proto::proto::*; -use tokio::io::{AsyncRead, AsyncWrite, ReadHalf, WriteHalf}; -use tokio::time::timeout; -use tokio_util::codec::{FramedRead, FramedWrite}; - -use std::sync::Arc; -use std::time::Duration; +mod backend; mod cli; use cli::Cli; -mod client; -use crate::client::BasicLdapClient; mod config; -use config::Config; mod dn; -use crate::dn::Dn; +mod error; +mod filter; +mod handler; mod op; +mod prelude; +use prelude::*; mod stream; -use stream::AbstractStream; const LDAP_CLIENT_IO_TIMEOUT: Duration = Duration::from_secs(1); -type CR = ReadHalf; -type CW = WriteHalf; - #[derive(Debug, Clone)] pub enum LdapError { TlsError, @@ -38,140 +27,6 @@ pub enum LdapError { InvalidQuery, } -// We allow the large enum to exist as we always do a mem swap from unbound to authenticated, so -// the memory layout penalty doesn't apply. -#[allow(clippy::large_enum_variant)] -enum ClientState { - Unbound, - Authenticated { - #[allow(dead_code)] - request_dn: String, - backend_dn: String, - client: BasicLdapClient, - }, -} - -pub async fn client_process( - mut r: FramedRead, - mut w: FramedWrite, - config: Arc, -) { - info!("Received new connection"); - - // We always start unbound. - let mut state = ClientState::Unbound; - - // 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 { - msgid, - op: LdapOp::BindRequest(lbr), - ctrl, - }, - ) => match op::bind::bind(&mut w, lbr, config.clone(), msgid, ctrl).await { - Ok(ns) => ns, - Err(_) => break, - }, - // Unbinds are always actioned. - ( - _, - LdapMsg { - msgid: _, - op: LdapOp::UnbindRequest, - ctrl: _, - }, - ) => { - break; - } - // Unbound handler - ( - ClientState::Unbound, - LdapMsg { - msgid, - op: LdapOp::SearchRequest(sr), - 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 are still not logged in, but we don't abort the session - Some(ClientState::Unbound) - } - - // Authenticated message handler. - // - Search - ( - ClientState::Authenticated { - client, - backend_dn: _, - request_dn: _, - }, - LdapMsg { - msgid, - op: LdapOp::SearchRequest(sr), - ctrl, - }, - ) => { - let search_req = op::search::SearchRequest { - sr, - msgid, - ctrl, - 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, - } - } - // Extended Requests - Generally whoami. - ( - ClientState::Authenticated { - request_dn: _, - backend_dn, - client: _, - }, - LdapMsg { - msgid, - op: LdapOp::ExtendedRequest(ler), - ctrl: _, - }, - ) => match op::ext::extop(&mut w, ler, msgid, backend_dn).await { - Ok(ns) => ns, - Err(_) => break, - }, - _ => { - log::debug!("unimplemented"); - None - } - }; - - if let Some(next_state) = next_state { - // Update the client state, dropping any former state. - state = next_state; - } - } -} - #[tokio::main(flavor = "current_thread")] async fn main() { if std::env::var("RUST_LOG").is_err() { @@ -192,7 +47,7 @@ async fn main() { "Loading configuration file {} failed!", cli.config.display() ); - error!("{}", e); + error!("{e}"); std::process::exit(1); } }; @@ -205,22 +60,26 @@ async fn main() { std::process::exit(1); } }; - info!("Listening on {:?}", listener); + info!("Listening on {listener:?}"); let state = Arc::new(config); loop { match listener.accept().await { - Ok((tcpstream, client_socket_addr)) => { + Ok((connection, client_socket_addr)) => { log::debug!("New connection from {client_socket_addr}"); - let (r, w) = tokio::io::split(tcpstream); - let r = FramedRead::new(r, LdapCodec::new(None, None)); - let w = FramedWrite::new(w, LdapCodec::new(None, None)); - tokio::spawn(client_process(r, w, state.clone())); + // let (r, w) = tokio::io::split(tcpstream); + // let r = FramedRead::new(r, LdapCodec::new(None, None)); + // let w = FramedWrite::new(w, LdapCodec::new(None, None)); + + let arc_state = state.clone(); + tokio::spawn(async move { + let mut connection = ClientConnection::new(connection, arc_state); + connection.event_loop().await; + }); } Err(e) => { - warn!("{}", e); - continue; + warn!("{e}"); } } } diff --git a/src/op/bind.rs b/src/op/bind.rs index 51b5171..a28e788 100644 --- a/src/op/bind.rs +++ b/src/op/bind.rs @@ -1,63 +1,133 @@ -use futures_util::SinkExt; -use ldap3_proto::LdapCodec; -use ldap3_proto::control::*; -use ldap3_proto::proto::*; -use tokio::io::AsyncWrite; -use tokio_util::codec::FramedWrite; +use crate::prelude::*; -use std::sync::Arc; +use crate::backend::{BackendClient, BackendError}; -use crate::{BasicLdapClient, ClientState, Config, Dn, LdapError}; +pub enum BindError { + Backend(BackendError), + NoRequestedBackend, + DomainNotFound(DomainNotFound), + InvalidDn(InvalidDnError), +} -// 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 { - res: LdapResult { - code: LdapResultCode::OperationsError, - matcheddn: "".to_string(), - message: msg.to_string(), - referral: vec![], - }, - saslcreds: None, - }), - ctrl: vec![], +impl From for BindError { + fn from(e: BackendError) -> Self { + Self::Backend(e) } } -pub async fn bind( - w: &mut FramedWrite, - mut lbr: LdapBindRequest, - config: Arc, - msgid: i32, - ctrl: Vec, -) -> Result, LdapError> { - trace!("{:?}", lbr); +impl From for BindError { + fn from(e: DomainNotFound) -> Self { + Self::DomainNotFound(e) + } +} - 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. +impl From for BindError { + fn from(e: InvalidDnError) -> Self { + Self::InvalidDn(e) + } +} + +impl ReturnError for BindError { + fn code(&self) -> LdapResultCode { + match self { + Self::Backend(e) => e.code(), + Self::NoRequestedBackend => LdapResultCode::OperationsError, + Self::DomainNotFound(e) => e.code(), + Self::InvalidDn(e) => e.code(), + } + } + + fn message(&self) -> String { + match self { + Self::Backend(e) => e.message(), + Self::NoRequestedBackend => { + "No domain name found to match a LDAP backend in bind dn".to_string() + } + Self::DomainNotFound(e) => e.message(), + Self::InvalidDn(e) => e.message(), + } + } +} + +impl BindError { + pub async fn error_message( + &self, + connection: &mut ClientConnection, + msgid: i32, + matcheddn: String, + ) -> Result<(), UnrecoverableError> { let resp_msg = LdapMsg { msgid, op: LdapOp::BindResponse(LdapBindResponse { res: LdapResult { - code: LdapResultCode::Success, - matcheddn: "".to_string(), - message: "".to_string(), + code: self.code(), + matcheddn, + message: self.message(), referral: vec![], }, saslcreds: None, }), ctrl: vec![], }; - w.send(resp_msg).await.map_err(|err| { - error!("Unable to send response: {err}"); - LdapError::Transport - })?; + + connection.send(resp_msg).await?; + Ok(()) + } +} + +pub async fn bind_success( + connection: &mut ClientConnection, + msgid: i32, + matcheddn: String, + message: String, + ctrl: Vec, +) -> Result<(), UnrecoverableError> { + let resp_msg = LdapMsg { + msgid, + op: LdapOp::BindResponse(LdapBindResponse { + res: LdapResult { + code: LdapResultCode::Success, + matcheddn, + message, + referral: vec![], + }, + saslcreds: None, + }), + ctrl, + }; + + connection.send(resp_msg).await?; + Ok(()) +} + +/// This method tries to bind the client to a backend. +/// +/// If the client submits an anonymous (empty dn) bind request, it is +/// always considered successful but is a no-op that does not bind +/// to an actual backend. This allows to support unbound search +/// via ldapsearch. +pub async fn bind( + connection: &mut ClientConnection, + mut lbr: LdapBindRequest, + msgid: i32, + ctrl: Vec, +) -> Result, UnrecoverableError> { + trace!("{lbr:?}"); + + if lbr.dn.is_empty() { + // 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. + bind_success( + connection, + msgid, + String::new(), + "Anonymous bind to ldap-rp reverse proxy".to_string(), + ctrl, + ) + .await?; + // We still treat the client as unbounded because it doesn't // have a session to a backend. return Ok(Some(ClientState::Unbound)); @@ -66,24 +136,37 @@ pub async fn bind( let request_dn = lbr.dn.clone(); debug!("Received bind request on DN: {}", lbr.dn); - let mut dn = Dn::from_dn_str(&lbr.dn)?; + let mut dn = match Dn::from_dn_str(&lbr.dn) { + Ok(dn) => dn, + Err(e) => { + BindError::from(e) + .error_message(connection, msgid, String::new()) + .await?; + // TODO: should we abort the connection here? + return Ok(Some(ClientState::Unbound)); + } + }; let Some(requested_domain) = dn.get_hostname() else { + BindError::NoRequestedBackend + .error_message(connection, msgid, String::new()) + .await?; debug!("No domain name CN found in DN: {}", lbr.dn); - return Err(LdapError::InvalidQuery); + return Ok(Some(ClientState::Unbound)); }; // Lowercase the domain systematically to allow matches let requested_domain = requested_domain.to_lowercase(); - let Some(mapping) = config - .mapping - .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); + let mapping = match connection.config.get_mapping(&requested_domain) { + Ok(mapping) => mapping, + Err(e) => { + BindError::from(e) + .error_message(connection, msgid, String::new()) + .await?; + // TODO: should we abort the connection here? + return Ok(Some(ClientState::Unbound)); + } }; debug!( @@ -95,57 +178,40 @@ pub async fn bind( 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:?}"); - // 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 - })?; - // Always bail. - return Ok(None); - } - }; - - let valid = match client.bind(lbr, ctrl).await { - Ok((bind_resp, ctrl)) => { - // Almost there, lets check the bind result. - let valid = bind_resp.res.code == LdapResultCode::Success; - - let resp_msg = LdapMsg { - msgid, - op: LdapOp::BindResponse(bind_resp), - ctrl, - }; - w.send(resp_msg).await.map_err(|err| { - error!("Unable to send response: {err}"); - LdapError::Transport - })?; - valid - } - Err(e) => { - error!("A client bind 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(None); - } - }; + let mut client = BackendClient::build(mapping.clone().into()).await?; + let (bind_resp, ctrl) = client.bind(lbr, ctrl).await?; + let valid = bind_resp.res.code == LdapResultCode::Success; if valid { - info!("Successful bind for `{request_dn}` -> `{backend_dn}`"); - Ok(Some(ClientState::Authenticated { + // TODO: we may want to edit the returned DN here + bind_success( + connection, + msgid, + // TODO: what matcheddn to write here? the backend or the request one? + String::new(), + "Anonymous bind to ldap-rp reverse proxy".to_string(), + ctrl, + ) + .await?; + + Ok(Some(ClientState::Authenticated(AuthenticatedClient { request_dn, backend_dn, client, - })) + }))) } else { - Ok(None) + // TODO: maybe we want to customize the returned error here. For now send it raw + let resp_msg = LdapMsg { + msgid, + op: LdapOp::BindResponse(LdapBindResponse { + res: bind_resp.res, + saslcreds: None, + }), + ctrl: vec![], + }; + connection.send(resp_msg).await?; + + // TODO: should we abort the connection here? + Ok(Some(ClientState::Unbound)) } } diff --git a/src/op/ext.rs b/src/op/ext.rs index 875a435..a3dfc26 100644 --- a/src/op/ext.rs +++ b/src/op/ext.rs @@ -1,24 +1,17 @@ -use futures_util::SinkExt; -use ldap3_proto::LdapCodec; -use ldap3_proto::proto::*; -use tokio::io::AsyncWrite; -use tokio_util::codec::FramedWrite; +use crate::prelude::*; -use crate::{ClientState, LdapError}; - -pub async fn extop( - w: &mut FramedWrite, +pub async fn extop( + connection: &mut ClientConnection, ler: LdapExtendedRequest, msgid: i32, - display_dn: &str, -) -> Result, LdapError> { +) -> Result, UnrecoverableError> { let op = match ler.name.as_str() { "1.3.6.1.4.1.4203.1.11.3" => LdapOp::ExtendedResponse(LdapExtendedResponse { res: LdapResult { code: LdapResultCode::Success, - matcheddn: "".to_string(), - message: "".to_string(), + matcheddn: String::new(), + message: String::new(), referral: vec![], }, name: None, @@ -27,8 +20,8 @@ pub async fn extop( _ => LdapOp::ExtendedResponse(LdapExtendedResponse { res: LdapResult { code: LdapResultCode::OperationsError, - matcheddn: "".to_string(), - message: "".to_string(), + matcheddn: String::new(), + message: String::new(), referral: vec![], }, name: None, @@ -36,16 +29,12 @@ pub async fn extop( }), }; - w.send(LdapMsg { - msgid, - op, - ctrl: vec![], - }) - .await - .map_err(|err| { - error!("Unable to send response: {err}"); - LdapError::Transport - })?; - + connection + .send(LdapMsg { + msgid, + op, + ctrl: vec![], + }) + .await?; Ok(None) } diff --git a/src/op/search.rs b/src/op/search.rs index 7237f47..9ad8fa3 100644 --- a/src/op/search.rs +++ b/src/op/search.rs @@ -1,295 +1,364 @@ -use futures_util::SinkExt; -use ldap3_proto::LdapCodec; -use ldap3_proto::control::*; -use ldap3_proto::proto::*; -use tokio::io::AsyncWrite; -use tokio_util::codec::FramedWrite; +use crate::prelude::*; -use std::sync::Arc; - -use crate::op::bind::bind_operror; -use crate::{BasicLdapClient, Config, Dn, LdapError}; - -pub struct SearchRequest<'a> { - pub sr: LdapSearchRequest, - pub msgid: i32, - pub ctrl: Vec, - pub client: &'a mut BasicLdapClient, -} +use crate::backend::{BackendClient, BackendError, BackendInfo}; +use crate::config::Mapping; +use crate::filter::mail::{MailDomainError, MailFilter}; #[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, +pub enum SearchError { + Backend(BackendError), + InvalidDn(InvalidDnError), + MailDomain(MailDomainError), + DomainNotFound(DomainNotFound), } -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", +impl SearchError { + pub async fn error_message( + &self, + connection: &mut ClientConnection, + msgid: i32, + ctrl: Vec, + ) -> Result<(), UnrecoverableError> { + let resp_msg = LdapMsg { + msgid, + op: LdapOp::SearchResultDone(LdapResult { + code: self.code(), + matcheddn: String::new(), + message: self.message(), + referral: vec![], + }), + ctrl, }; - write!(f, "{}", msg) + + connection.send(resp_msg).await?; + Ok(()) } } -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); +impl From for SearchError { + fn from(e: BackendError) -> Self { + Self::Backend(e) } - - 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, +impl From for SearchError { + fn from(e: InvalidDnError) -> Self { + Self::InvalidDn(e) + } +} + +impl From for SearchError { + fn from(e: MailDomainError) -> Self { + Self::MailDomain(e) + } +} + +impl From for SearchError { + fn from(e: DomainNotFound) -> Self { + Self::DomainNotFound(e) + } +} + +impl ReturnError for SearchError { + fn code(&self) -> LdapResultCode { + match self { + Self::Backend(e) => e.code(), + Self::InvalidDn(e) => e.code(), + Self::MailDomain(e) => e.code(), + Self::DomainNotFound(e) => e.code(), } } - 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) - } + fn message(&self) -> String { + match self { + Self::Backend(e) => e.message(), + Self::InvalidDn(e) => e.message(), + Self::MailDomain(e) => e.message(), + Self::DomainNotFound(e) => e.message(), } - _ => 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 function takes a LDAP search request, and rewrites it for the correct backend. +/// +/// Produces an error if no backend can be implied from the request, if no matching backend exists, +/// or if the request is otherwise malformed. +/// +/// This function rewrites: +/// +/// - the requested DN with the matching backend DN (eg. `a.localhost` -> `example.com`) +/// - the mail filter with a normalized value (eg. `A@A.localhost` -> `a@a.localhost`) +/// - TODO: memberOf attrs +fn rewrite_search_request( + // The initial search request is passed by value so it cannot be reused by mistake + #[allow(clippy::needless_pass_by_value)] + sr: LdapSearchRequest, + config: &Arc, +) -> Result<(LdapSearchRequest, Mapping), SearchError> { // 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(()); - } - } + let mail_filter = + MailFilter::from_search_filter(&sr.filter).map_err(SearchError::MailDomain)?; + let mapping = config.get_mapping(&mail_filter.domain)?; // 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, + let mut new_search = sr.clone(); + new_search.base = dn; + debug!( + "Rewrote anonymous search DN from {} to {}", + sr.base, new_search.base + ); + + // TODO: support memberOf and other stuff + Ok((new_search, mapping)) +} + +pub async fn search_success( + connection: &mut ClientConnection, + msgid: i32, + entries: Vec<(LdapSearchResultEntry, Vec)>, + result: LdapResult, + ctrl: Vec, +) -> Result<(), UnrecoverableError> { + for (entry, ctrl) in entries { + debug!("Search result: {entry:?}"); + connection + .send(LdapMsg { + msgid, + op: LdapOp::SearchResultEntry(entry), + ctrl, + }) + .await?; + } + + debug!("Search result done: {result:?}"); + connection + .send(LdapMsg { + msgid, + op: LdapOp::SearchResultDone(result), + ctrl, + }) + .await?; + + Ok(()) +} + +pub async fn search_by_email_attribute( + connection: &mut ClientConnection, + sr: LdapSearchRequest, + msgid: i32, + ctrl: Vec, + config: Arc, +) -> Result<(), UnrecoverableError> { + 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 (sr, mapping) = match rewrite_search_request(sr, &config) { + Ok((sr, mapping)) => (sr, mapping), 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. + e.error_message(connection, msgid, ctrl).await?; 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)?; + let backend: BackendInfo = mapping.clone().into(); + let mut client = BackendClient::build(backend).await?; + + let lbr = LdapBindRequest { + dn: mapping.user.clone(), + cred: LdapBindCred::Simple(mapping.password.clone()), + }; + + let (bind_resp, ctrl) = client.bind(lbr.clone(), ctrl).await?; + let valid = bind_resp.res.code == LdapResultCode::Success; + if !valid { + // Here invalid credentials is not recoverable (unlike bind where it's just + // business as usual) + SearchError::from(client.backend.err_invalid_credentials()) + .error_message(connection, msgid, ctrl) + .await?; + return Ok(()); + } + + // Query the backend and rewrite matching dns with the mapping vhost + let (mut entries, result, ctrl) = client.search(sr, ctrl).await?; + + for (entry, ctrl) in &mut entries { + trace!("Search result from backend: {entry:?}"); + // TODO: maybe don't fail the whole search request here??? + let mut dn = match Dn::from_dn_str(&lbr.dn) { + Ok(dn) => dn, + Err(e) => { + SearchError::from(e) + .error_message(connection, msgid, ctrl.clone()) + .await?; + return Ok(()); + } + }; 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: {entry:?}"); } - 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 - })?; + search_success(connection, msgid, entries, result, ctrl).await?; Ok(()) } -pub async fn search( - w: &mut FramedWrite, - search_request: SearchRequest<'_>, -) -> Result<(), LdapError> { - debug!("{:?}", search_request.sr); - let SearchRequest { - sr, - msgid, - ctrl, - client, - } = search_request; +#[cfg(test)] +mod tests { + use ldap3_proto::LdapSearchScope; + use ldap3_proto::proto::LdapDerefAliases; - 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 - })?; + use super::*; + use crate::filter::{search_filter_and, search_filter_eq, search_filter_or}; - // Error sent, return with no state change. - return Ok(()); + fn search_query(dn: &str, filter: LdapFilter) -> LdapSearchRequest { + LdapSearchRequest { + base: dn.to_string(), + scope: LdapSearchScope::Subtree, + aliases: LdapDerefAliases::Never, + sizelimit: 0, + timelimit: 0, + typesonly: false, + filter, + attrs: vec![ + "description", + "userpassword", + "pwdchangetime", + "memberof", + "mailalias", + "mail", + "objectclass", + ] + .into_iter() + .map(|x| x.to_string()) + .collect(), } - }; - - 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), - ctrl, - }) - .await - .map_err(|err| { - error!("Unable to send response: {err}"); - LdapError::Transport - })?; + fn sample_config(backends: &[&str]) -> Config { + let mut c = Config::default(); + for backend in backends { + c.mapping.push(Mapping { + from: backend.to_string(), + to: "example.com".to_string(), + backend: format!("/tmp/sample-mapping-{backend}.sock"), + user: "admin".to_string(), + password: "adminadmin".to_string(), + }); + } + c + } - // No state change - Ok(()) + #[test] + fn stalwart_default_no_dn_hostname() { + // (&(objectClass=inetOrgPerson)(mail=?)) + let filter = search_filter_and(&[ + search_filter_eq("mail", "a@a.localhost"), + search_filter_eq("objectClass", "inetOrgPerson"), + ]); + + let mut sr = search_query("ou=people", filter); + let config = Arc::new(sample_config(&["a.localhost"])); + let res = rewrite_search_request(sr.clone(), config.clone()); + println!("{res:?}"); + let (new_sr, mapping) = res.unwrap(); + + assert_eq!(mapping, config.get_mapping("a.localhost").unwrap()); + // TODO: soon we may want to lookup groups so we don't want to hardcore ou=people here + sr.base = "ou=people,dc=example,dc=com".to_string(); + assert_eq!(sr, new_sr); + } + + #[test] + fn stalwart_default_dummy_dn_hostname() { + // (&(objectClass=inetOrgPerson)(mail=?)) + let filter = search_filter_and(&[ + search_filter_eq("mail", "a@a.localhost"), + search_filter_eq("objectClass", "inetOrgPerson"), + ]); + + let mut sr = search_query("ou=people,dc=replace,dc=me", filter); + let config = Arc::new(sample_config(&["a.localhost"])); + let res = rewrite_search_request(sr.clone(), config.clone()); + println!("{res:?}"); + let (new_sr, mapping) = res.unwrap(); + + assert_eq!(mapping, config.get_mapping("a.localhost").unwrap()); + // TODO: soon we may want to lookup groups so we don't want to hardcore ou=people here + sr.base = "ou=people,dc=example,dc=com".to_string(); + assert_eq!(sr, new_sr); + } + + #[test] + fn stalwart_default_normalized_dn() { + // (&(objectClass=inetOrgPerson)(mail=?)) + let filter = search_filter_and(&[ + search_filter_eq("mail", "A@A.localhost"), + search_filter_eq("objectClass", "inetOrgPerson"), + ]); + + let mut sr = search_query("ou=people,dc=A,DC=LOCALHOST", filter); + let config = Arc::new(sample_config(&["A.localhost"])); + let res = rewrite_search_request(sr.clone(), config.clone()); + println!("{res:?}"); + let (new_sr, mapping) = res.unwrap(); + + assert_eq!(mapping, config.get_mapping("A.localhost").unwrap()); + // TODO: soon we may want to lookup groups so we don't want to hardcore ou=people here + sr.base = "ou=people,dc=example,dc=com".to_string(); + assert_eq!(sr, new_sr); + } + + // TODO: support member and memberOf (we don't rewrite the DC yet) + #[test] + fn stalwart_lldap_example_no_dn() { + // &(|(objectClass=person)(member=cn=mail,ou=groups,dc=example,dc=org))(uid=?)) + let filter = search_filter_and(&[ + search_filter_eq("uid", "a"), + search_filter_or(&[ + search_filter_eq("objectClass", "person"), + search_filter_eq("member", "cn=mail,ou=groups,dc=a,dc=localhost"), + ]), + ]); + let mut sr = search_query("ou=people", filter); + let config = Arc::new(sample_config(&["a.localhost"])); + let res = rewrite_search_request(sr.clone(), config.clone()); + println!("{res:?}"); + let (new_sr, mapping) = res.unwrap(); + + assert_eq!(mapping, config.get_mapping("a.localhost").unwrap()); + // TODO: soon we may want to lookup groups so we don't want to hardcore ou=people here + sr.base = "ou=people,dc=example,dc=com".to_string(); + assert_eq!(sr, new_sr); + } + + // TODO: support member and memberOf (we don't rewrite the DC yet) + #[test] + fn stalwart_lldap_example_dummy_dn() { + // &(|(objectClass=person)(member=cn=mail,ou=groups,dc=example,dc=org))(uid=?)) + let filter = search_filter_and(&[ + search_filter_eq("uid", "a"), + search_filter_or(&[ + search_filter_eq("objectClass", "person"), + search_filter_eq("member", "cn=mail,ou=groups,dc=a,dc=localhost"), + ]), + ]); + let mut sr = search_query("ou=people,dc=a,dc=localhost", filter); + let config = Arc::new(sample_config(&["a.localhost"])); + let res = rewrite_search_request(sr.clone(), config.clone()); + println!("{res:?}"); + let (new_sr, mapping) = res.unwrap(); + + assert_eq!(mapping, config.get_mapping("a.localhost").unwrap()); + // TODO: soon we may want to lookup groups so we don't want to hardcore ou=people here + sr.base = "ou=people,dc=example,dc=com".to_string(); + assert_eq!(sr, new_sr); + } } diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 0000000..f44b637 --- /dev/null +++ b/src/prelude.rs @@ -0,0 +1,27 @@ +pub use futures_util::SinkExt; +pub use futures_util::StreamExt; +pub use ldap3_proto::control::LdapControl; +pub use ldap3_proto::proto::*; +pub use ldap3_proto::{LdapCodec, LdapMsg}; +pub use tokio::io::{ReadHalf, WriteHalf}; +pub use tokio::net::{TcpStream, UnixStream}; +pub use tokio::time::timeout; +pub use tokio_listener::Connection; +pub use tokio_util::codec::{FramedRead, FramedWrite}; + +pub use std::sync::Arc; +pub use std::time::Duration; + +pub use crate::config::Config; +pub use crate::config::DomainNotFound; +pub use crate::dn::Dn; +pub use crate::dn::InvalidDnError; +pub use crate::error::ReturnError; +pub use crate::handler::client_process::AuthenticatedClient; +pub use crate::handler::client_process::ClientConnection; +pub use crate::handler::client_process::ClientState; +pub use crate::handler::client_process::UnrecoverableError; +pub use crate::op::bind::bind as op_bind; +pub use crate::op::ext::extop as op_ext; +pub use crate::op::search::search_by_email_attribute as op_search_by_email_attribute; +pub use crate::stream::AbstractStream; diff --git a/src/stream.rs b/src/stream.rs index d5fd782..6a853f2 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -1,5 +1,6 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::{TcpStream, UnixStream}; +use tokio_listener::Connection; use std::io::Result; use std::marker::Unpin; @@ -9,6 +10,7 @@ use std::task::{Context, Poll}; pub enum AbstractStream { Tcp(TcpStream), Uds(UnixStream), + Listener(Connection), } impl From for AbstractStream { @@ -23,6 +25,12 @@ impl From for AbstractStream { } } +impl From for AbstractStream { + fn from(stream: Connection) -> Self { + Self::Listener(stream) + } +} + impl Unpin for AbstractStream {} impl AsyncRead for AbstractStream { @@ -34,6 +42,7 @@ impl AsyncRead for AbstractStream { match &mut *self { Self::Tcp(stream) => pin!(stream).poll_read(cx, buf), Self::Uds(stream) => pin!(stream).poll_read(cx, buf), + Self::Listener(stream) => pin!(stream).poll_read(cx, buf), } } } @@ -47,6 +56,7 @@ impl AsyncWrite for AbstractStream { match &mut *self { Self::Tcp(stream) => pin!(stream).poll_write(cx, buf), Self::Uds(stream) => pin!(stream).poll_write(cx, buf), + Self::Listener(stream) => pin!(stream).poll_write(cx, buf), } } @@ -54,6 +64,7 @@ impl AsyncWrite for AbstractStream { match &mut *self { Self::Tcp(stream) => pin!(stream).poll_flush(cx), Self::Uds(stream) => pin!(stream).poll_flush(cx), + Self::Listener(stream) => pin!(stream).poll_flush(cx), } } @@ -61,6 +72,7 @@ impl AsyncWrite for AbstractStream { match &mut *self { Self::Tcp(stream) => pin!(stream).poll_shutdown(cx), Self::Uds(stream) => pin!(stream).poll_shutdown(cx), + Self::Listener(stream) => pin!(stream).poll_shutdown(cx), } } }