refactor: EVERYTHING

This commit is contained in:
selfhoster selfhoster 2026-08-31 19:11:34 +02:00
commit 7ab0898413
17 changed files with 1410 additions and 728 deletions

View file

@ -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-listener = { version = "0.5.2", features = ["serde"] }
tokio-util = "0.7.19" tokio-util = "0.7.19"
toml = "1.1.4" toml = "1.1.4"
[lints.clippy]
suspicious = "deny"
complexity = "deny"
pedantic = "deny"
perf = "deny"
style = "deny"
# cargo = "deny"

16
config.toml Normal file
View file

@ -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"

343
src/backend.rs Normal file
View file

@ -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<crate::config::Mapping> 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<AbstractStream, BackendError> {
// 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<ReadHalf<AbstractStream>, LdapCodec>,
w: FramedWrite<WriteHalf<AbstractStream>, 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<Self, BackendError> {
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<LdapControl>,
) -> Result<(LdapBindResponse, Vec<LdapControl>), 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<LdapControl>,
) -> Result<
(
Vec<(LdapSearchResultEntry, Vec<LdapControl>)>,
LdapResult,
Vec<LdapControl>,
),
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());
}
}
}
}
}

View file

@ -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<CR, LdapCodec>,
w: FramedWrite<CW, LdapCodec>,
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<Self, LdapError> {
// 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<LdapControl>,
) -> Result<(LdapBindResponse, Vec<LdapControl>), 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<LdapControl>,
) -> Result<
(
Vec<(LdapSearchResultEntry, Vec<LdapControl>)>,
LdapResult,
Vec<LdapControl>,
),
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);
}
}
}
}
}

View file

@ -5,7 +5,22 @@ use tokio_listener::{Listener, ListenerAddress, SystemOptions, UserOptions};
use std::net::{Ipv6Addr, SocketAddrV6}; use std::net::{Ipv6Addr, SocketAddrV6};
use std::path::Path; 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 struct Config {
pub listen: Option<ListenerAddress>, pub listen: Option<ListenerAddress>,
pub mapping: Vec<Mapping>, pub mapping: Vec<Mapping>,
@ -17,6 +32,14 @@ impl Config {
Ok(toml::from_slice(&content)?) Ok(toml::from_slice(&content)?)
} }
pub fn get_mapping(&self, requested_domain: &str) -> Result<Mapping, DomainNotFound> {
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<Listener> { pub async fn listener(&self) -> anyhow::Result<Listener> {
let listener_addr = if let Some(listen) = &self.listen { let listener_addr = if let Some(listen) = &self.listen {
// For now we only support ADDR:PORT and Unix domain sockets // For now we only support ADDR:PORT and Unix domain sockets
@ -38,7 +61,7 @@ impl Config {
error!( error!(
"To use unix domain sockets, use relative or absolute paths, eg. `./ldap.sock` or `/var/run/ldap.sock`" "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 { } else {
@ -47,8 +70,8 @@ impl Config {
&ListenerAddress::Tcp(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 389, 0, 0).into()) &ListenerAddress::Tcp(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 389, 0, 0).into())
}; };
let system_options: SystemOptions = Default::default(); let system_options = SystemOptions::default();
let mut user_options: UserOptions = Default::default(); let mut user_options = UserOptions::default();
// If there's a left over socket, delete it instead of erroring. // If there's a left over socket, delete it instead of erroring.
// If another daemon is still listening over there, it will no longer // If another daemon is still listening over there, it will no longer
// receive connections. // receive connections.
@ -59,7 +82,7 @@ impl Config {
} }
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct Mapping { pub struct Mapping {
pub from: String, pub from: String,
pub to: String, pub to: String,

View file

@ -1,7 +1,20 @@
use indexmap::IndexMap; use indexmap::IndexMap;
use ldap3::dn_escape; 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. /// 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 /// However, if we find a really funny request such as `dc=foo=bar`, then
/// we return an error to the client. /// we return an error to the client.
pub fn from_dn_str(input: &str) -> Result<Self, LdapError> { pub fn from_dn_str(input: &str) -> Result<Self, InvalidDnError> {
let mut keys: IndexMap<String, Vec<String>> = IndexMap::new(); let mut keys: IndexMap<String, Vec<String>> = IndexMap::new();
if input == "" { if input.is_empty() {
return Ok(Self { keys }); return Ok(Self { keys });
} }
@ -34,7 +47,7 @@ impl Dn {
if query_parts.clone().count() != 2 { if query_parts.clone().count() != 2 {
// Bad request // Bad request
log::debug!("Invalid query DN: {input}"); log::debug!("Invalid query DN: {input}");
return Err(LdapError::InvalidQuery); return Err(InvalidDnError(input.to_string()));
} }
let key = query_parts.next().unwrap(); let key = query_parts.next().unwrap();

6
src/error.rs Normal file
View file

@ -0,0 +1,6 @@
use ldap3_proto::LdapResultCode;
pub trait ReturnError {
fn code(&self) -> LdapResultCode;
fn message(&self) -> String;
}

241
src/filter/mail.rs Normal file
View file

@ -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<Self, MailDomainError> {
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<Self, MailDomainError> {
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<Self, MailDomainError> {
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<Option<Self>, 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"),
// ]),
// ]);
// }
}

19
src/filter/mod.rs Normal file
View file

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

View file

@ -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<BackendError> for UnrecoverableError {
fn from(e: BackendError) -> Self {
Self::Backend(e)
}
}
pub struct ClientConnection {
r: FramedRead<ReadHalf<AbstractStream>, LdapCodec>,
w: FramedWrite<WriteHalf<AbstractStream>, LdapCodec>,
pub config: Arc<Config>,
}
// 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<Config>) -> 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<Option<ClientState>>:
// - 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<Option<ClientState>, 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)
}
}
}
}

3
src/handler/mod.rs Normal file
View file

@ -0,0 +1,3 @@
// pub mod bound;
pub mod client_process;
// pub mod unbound;

View file

@ -2,33 +2,22 @@
extern crate log; extern crate log;
use clap::Parser; 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; mod cli;
use cli::Cli; use cli::Cli;
mod client;
use crate::client::BasicLdapClient;
mod config; mod config;
use config::Config;
mod dn; mod dn;
use crate::dn::Dn; mod error;
mod filter;
mod handler;
mod op; mod op;
mod prelude;
use prelude::*;
mod stream; mod stream;
use stream::AbstractStream;
const LDAP_CLIENT_IO_TIMEOUT: Duration = Duration::from_secs(1); const LDAP_CLIENT_IO_TIMEOUT: Duration = Duration::from_secs(1);
type CR = ReadHalf<AbstractStream>;
type CW = WriteHalf<AbstractStream>;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum LdapError { pub enum LdapError {
TlsError, TlsError,
@ -38,140 +27,6 @@ pub enum LdapError {
InvalidQuery, 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<W: AsyncWrite + Unpin, R: AsyncRead + Unpin>(
mut r: FramedRead<R, LdapCodec>,
mut w: FramedWrite<W, LdapCodec>,
config: Arc<Config>,
) {
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")] #[tokio::main(flavor = "current_thread")]
async fn main() { async fn main() {
if std::env::var("RUST_LOG").is_err() { if std::env::var("RUST_LOG").is_err() {
@ -192,7 +47,7 @@ async fn main() {
"Loading configuration file {} failed!", "Loading configuration file {} failed!",
cli.config.display() cli.config.display()
); );
error!("{}", e); error!("{e}");
std::process::exit(1); std::process::exit(1);
} }
}; };
@ -205,22 +60,26 @@ async fn main() {
std::process::exit(1); std::process::exit(1);
} }
}; };
info!("Listening on {:?}", listener); info!("Listening on {listener:?}");
let state = Arc::new(config); let state = Arc::new(config);
loop { loop {
match listener.accept().await { match listener.accept().await {
Ok((tcpstream, client_socket_addr)) => { Ok((connection, client_socket_addr)) => {
log::debug!("New connection from {client_socket_addr}"); log::debug!("New connection from {client_socket_addr}");
let (r, w) = tokio::io::split(tcpstream); // let (r, w) = tokio::io::split(tcpstream);
let r = FramedRead::new(r, LdapCodec::new(None, None)); // let r = FramedRead::new(r, LdapCodec::new(None, None));
let w = FramedWrite::new(w, LdapCodec::new(None, None)); // let w = FramedWrite::new(w, LdapCodec::new(None, None));
tokio::spawn(client_process(r, w, state.clone()));
let arc_state = state.clone();
tokio::spawn(async move {
let mut connection = ClientConnection::new(connection, arc_state);
connection.event_loop().await;
});
} }
Err(e) => { Err(e) => {
warn!("{}", e); warn!("{e}");
continue;
} }
} }
} }

View file

@ -1,63 +1,133 @@
use futures_util::SinkExt; use crate::prelude::*;
use ldap3_proto::LdapCodec;
use ldap3_proto::control::*;
use ldap3_proto::proto::*;
use tokio::io::AsyncWrite;
use tokio_util::codec::FramedWrite;
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 impl From<BackendError> for BindError {
// (bind, search) and custom error codes fn from(e: BackendError) -> Self {
pub fn bind_operror(msgid: i32, msg: &dyn ToString) -> LdapMsg { Self::Backend(e)
LdapMsg {
msgid,
op: LdapOp::BindResponse(LdapBindResponse {
res: LdapResult {
code: LdapResultCode::OperationsError,
matcheddn: "".to_string(),
message: msg.to_string(),
referral: vec![],
},
saslcreds: None,
}),
ctrl: vec![],
} }
} }
pub async fn bind<W: AsyncWrite + Unpin>( impl From<DomainNotFound> for BindError {
w: &mut FramedWrite<W, LdapCodec>, fn from(e: DomainNotFound) -> Self {
mut lbr: LdapBindRequest, Self::DomainNotFound(e)
config: Arc<Config>, }
msgid: i32, }
ctrl: Vec<LdapControl>,
) -> Result<Option<ClientState>, LdapError> {
trace!("{:?}", lbr);
if lbr.dn == "" { impl From<InvalidDnError> for BindError {
// Here we pretend to have successfully bound so that fn from(e: InvalidDnError) -> Self {
// a client performing an anonymous bind can proceed with Self::InvalidDn(e)
// more requests (such as a search request). }
// This supports ldap search which always performs a bind. }
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 { let resp_msg = LdapMsg {
msgid, msgid,
op: LdapOp::BindResponse(LdapBindResponse { op: LdapOp::BindResponse(LdapBindResponse {
res: LdapResult { res: LdapResult {
code: LdapResultCode::Success, code: self.code(),
matcheddn: "".to_string(), matcheddn,
message: "".to_string(), message: self.message(),
referral: vec![], referral: vec![],
}, },
saslcreds: None, saslcreds: None,
}), }),
ctrl: vec![], ctrl: vec![],
}; };
w.send(resp_msg).await.map_err(|err| {
error!("Unable to send response: {err}"); connection.send(resp_msg).await?;
LdapError::Transport Ok(())
})?; }
}
pub async fn bind_success(
connection: &mut ClientConnection,
msgid: i32,
matcheddn: String,
message: String,
ctrl: Vec<LdapControl>,
) -> 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<LdapControl>,
) -> Result<Option<ClientState>, 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 // We still treat the client as unbounded because it doesn't
// have a session to a backend. // have a session to a backend.
return Ok(Some(ClientState::Unbound)); return Ok(Some(ClientState::Unbound));
@ -66,24 +136,37 @@ pub async fn bind<W: AsyncWrite + Unpin>(
let request_dn = lbr.dn.clone(); let request_dn = lbr.dn.clone();
debug!("Received bind request on DN: {}", lbr.dn); 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 { 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); 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 // Lowercase the domain systematically to allow matches
let requested_domain = requested_domain.to_lowercase(); let requested_domain = requested_domain.to_lowercase();
let Some(mapping) = config let mapping = match connection.config.get_mapping(&requested_domain) {
.mapping Ok(mapping) => mapping,
.iter() Err(e) => {
.find(|x| x.from.to_lowercase() == requested_domain) BindError::from(e)
else { .error_message(connection, msgid, String::new())
// TODO: we should probably return an error to the client here .await?;
debug!("No mapping found for domain {requested_domain}"); // TODO: should we abort the connection here?
return Err(LdapError::InvalidQuery); return Ok(Some(ClientState::Unbound));
}
}; };
debug!( debug!(
@ -95,57 +178,40 @@ pub async fn bind<W: AsyncWrite + Unpin>(
let backend_dn = lbr.dn.clone(); let backend_dn = lbr.dn.clone();
// We need the client to connect *and* bind to proceed here! // We need the client to connect *and* bind to proceed here!
let mut client = match BasicLdapClient::build(&mapping.backend).await { let mut client = BackendClient::build(mapping.clone().into()).await?;
Ok(c) => c, let (bind_resp, ctrl) = client.bind(lbr, ctrl).await?;
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 valid = bind_resp.res.code == LdapResultCode::Success;
if valid { if valid {
info!("Successful bind for `{request_dn}` -> `{backend_dn}`"); // TODO: we may want to edit the returned DN here
Ok(Some(ClientState::Authenticated { 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, request_dn,
backend_dn, backend_dn,
client, client,
})) })))
} else { } 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))
} }
} }

View file

@ -1,24 +1,17 @@
use futures_util::SinkExt; use crate::prelude::*;
use ldap3_proto::LdapCodec;
use ldap3_proto::proto::*;
use tokio::io::AsyncWrite;
use tokio_util::codec::FramedWrite;
use crate::{ClientState, LdapError}; pub async fn extop(
connection: &mut ClientConnection,
pub async fn extop<W: AsyncWrite + Unpin>(
w: &mut FramedWrite<W, LdapCodec>,
ler: LdapExtendedRequest, ler: LdapExtendedRequest,
msgid: i32, msgid: i32,
display_dn: &str, display_dn: &str,
) -> Result<Option<ClientState>, LdapError> { ) -> Result<Option<ClientState>, UnrecoverableError> {
let op = match ler.name.as_str() { let op = match ler.name.as_str() {
"1.3.6.1.4.1.4203.1.11.3" => LdapOp::ExtendedResponse(LdapExtendedResponse { "1.3.6.1.4.1.4203.1.11.3" => LdapOp::ExtendedResponse(LdapExtendedResponse {
res: LdapResult { res: LdapResult {
code: LdapResultCode::Success, code: LdapResultCode::Success,
matcheddn: "".to_string(), matcheddn: String::new(),
message: "".to_string(), message: String::new(),
referral: vec![], referral: vec![],
}, },
name: None, name: None,
@ -27,8 +20,8 @@ pub async fn extop<W: AsyncWrite + Unpin>(
_ => LdapOp::ExtendedResponse(LdapExtendedResponse { _ => LdapOp::ExtendedResponse(LdapExtendedResponse {
res: LdapResult { res: LdapResult {
code: LdapResultCode::OperationsError, code: LdapResultCode::OperationsError,
matcheddn: "".to_string(), matcheddn: String::new(),
message: "".to_string(), message: String::new(),
referral: vec![], referral: vec![],
}, },
name: None, name: None,
@ -36,16 +29,12 @@ pub async fn extop<W: AsyncWrite + Unpin>(
}), }),
}; };
w.send(LdapMsg { connection
msgid, .send(LdapMsg {
op, msgid,
ctrl: vec![], op,
}) ctrl: vec![],
.await })
.map_err(|err| { .await?;
error!("Unable to send response: {err}");
LdapError::Transport
})?;
Ok(None) Ok(None)
} }

View file

@ -1,295 +1,364 @@
use futures_util::SinkExt; use crate::prelude::*;
use ldap3_proto::LdapCodec;
use ldap3_proto::control::*;
use ldap3_proto::proto::*;
use tokio::io::AsyncWrite;
use tokio_util::codec::FramedWrite;
use std::sync::Arc; use crate::backend::{BackendClient, BackendError, BackendInfo};
use crate::config::Mapping;
use crate::op::bind::bind_operror; use crate::filter::mail::{MailDomainError, MailFilter};
use crate::{BasicLdapClient, Config, Dn, LdapError};
pub struct SearchRequest<'a> {
pub sr: LdapSearchRequest,
pub msgid: i32,
pub ctrl: Vec<LdapControl>,
pub client: &'a mut BasicLdapClient,
}
#[derive(Debug)] #[derive(Debug)]
pub enum MailDomainError { pub enum SearchError {
/// mail value not user@domain format Backend(BackendError),
InvalidMail, InvalidDn(InvalidDnError),
/// No mail filter found in the search query MailDomain(MailDomainError),
NoMailFilter, DomainNotFound(DomainNotFound),
/// Filter is not And/Or/Equality for which we can find a mail filter
InvalidFilter,
} }
impl std::fmt::Display for MailDomainError { impl SearchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { pub async fn error_message(
let msg = match self { &self,
Self::InvalidMail => { connection: &mut ClientConnection,
"No valid email address requested in mail filter in search request" msgid: i32,
} ctrl: Vec<LdapControl>,
Self::NoMailFilter => "No mail filter found in search request", ) -> Result<(), UnrecoverableError> {
Self::InvalidFilter => "No AND/OR/EQUALITY filter found in search request", 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 {} impl From<BackendError> for SearchError {
fn from(e: BackendError) -> Self {
pub fn mail_username_domain_from_equality_filter( Self::Backend(e)
attr: &str,
value: &str,
) -> Result<Option<(String, String)>, MailDomainError> {
if attr != "mail" {
return Ok(None);
} }
let mut parts = value.split('@');
let username = parts.next().unwrap();
let Some(domain) = parts.next() else {
debug!("Not a valid username@domain email: {value}");
return Err(MailDomainError::InvalidMail);
};
if parts.next().is_some() {
debug!("Too many parts in mail address");
return Err(MailDomainError::InvalidMail);
}
return Ok(Some((username.to_string(), domain.to_string())));
} }
pub fn mail_username_domain_from_filters( impl From<InvalidDnError> for SearchError {
filters: &[LdapFilter], fn from(e: InvalidDnError) -> Self {
) -> Result<(String, String), MailDomainError> { Self::InvalidDn(e)
for filter in filters { }
match filter { }
LdapFilter::Equality(attr, value) => {
if let Some(found) = mail_username_domain_from_equality_filter(attr, value)? { impl From<MailDomainError> for SearchError {
return Ok(found); fn from(e: MailDomainError) -> Self {
} Self::MailDomain(e)
} }
_ => continue, }
impl From<DomainNotFound> 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"); fn message(&self) -> String {
return Err(MailDomainError::NoMailFilter); match self {
} Self::Backend(e) => e.message(),
Self::InvalidDn(e) => e.message(),
pub fn mail_username_domain_from_filter( Self::MailDomain(e) => e.message(),
filter: &LdapFilter, Self::DomainNotFound(e) => e.message(),
) -> Result<(String, String), MailDomainError> {
match filter {
LdapFilter::And(filters) => mail_username_domain_from_filters(&filters),
LdapFilter::Or(filters) => mail_username_domain_from_filters(&filters),
LdapFilter::Equality(attr, value) => {
if let Some(domain) = mail_username_domain_from_equality_filter(attr, value)? {
Ok(domain)
} else {
debug!("No mail filter found");
Err(MailDomainError::NoMailFilter)
}
} }
_ => Err(MailDomainError::InvalidFilter),
} }
} }
pub async fn search_by_email_attribute<W: AsyncWrite + Unpin>( /// This function takes a LDAP search request, and rewrites it for the correct backend.
w: &mut FramedWrite<W, LdapCodec>, ///
mut sr: LdapSearchRequest, /// Produces an error if no backend can be implied from the request, if no matching backend exists,
msgid: i32, /// or if the request is otherwise malformed.
ctrl: Vec<LdapControl>, ///
config: Arc<Config>, /// This function rewrites:
) -> Result<(), LdapError> { ///
debug!("unbound search by email attribute {:?}", sr); /// - 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<Config>,
) -> Result<(LdapSearchRequest, Mapping), SearchError> {
// This is hardcoded to mail attr with AND filter for stalwart behavior // This is hardcoded to mail attr with AND filter for stalwart behavior
// We also support direct EQUALITY and OR filters just in case // We also support direct EQUALITY and OR filters just in case
let (_requested_username, requested_domain) = match mail_username_domain_from_filter(&sr.filter) let mail_filter =
{ MailFilter::from_search_filter(&sr.filter).map_err(SearchError::MailDomain)?;
Ok(found) => { let mapping = config.get_mapping(&mail_filter.domain)?;
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 // Now edit the search base DN to match what the backend server expects
let mut dn = Dn::from_dn_str(&sr.base)?; let mut dn = Dn::from_dn_str(&sr.base)?;
// Maybe there was no hostname to begin with, force to add it // Maybe there was no hostname to begin with, force to add it
dn.set_hostname(&mapping.to, true); dn.set_hostname(&mapping.to, true);
let dn = dn.to_dn_string(); 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 mut new_search = sr.clone();
let (entries, result, ctrl) = match client.search(sr, ctrl).await { new_search.base = dn;
Ok(data) => data, 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<LdapControl>)>,
result: LdapResult,
ctrl: Vec<LdapControl>,
) -> 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<LdapControl>,
config: Arc<Config>,
) -> 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) => { Err(e) => {
error!("A client search error has occurred: {e:?}"); e.error_message(connection, msgid, ctrl).await?;
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(()); return Ok(());
} }
}; };
for (mut entry, ctrl) in entries { let backend: BackendInfo = mapping.clone().into();
trace!("Search result from backend: {:?}", entry); let mut client = BackendClient::build(backend).await?;
// TODO: maybe don't fail the whole request here???
let mut dn = Dn::from_dn_str(&entry.dn)?; 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); dn.set_hostname(&mapping.from, false);
entry.dn = dn.to_dn_string(); entry.dn = dn.to_dn_string();
debug!("Search result: {:?}", entry); 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); search_success(connection, msgid, entries, result, ctrl).await?;
w.send(LdapMsg {
msgid,
op: LdapOp::SearchResultDone(result),
ctrl,
})
.await
.map_err(|err| {
error!("Unable to send response: {err}");
LdapError::Transport
})?;
Ok(()) Ok(())
} }
pub async fn search<W: AsyncWrite + Unpin>( #[cfg(test)]
w: &mut FramedWrite<W, LdapCodec>, mod tests {
search_request: SearchRequest<'_>, use ldap3_proto::LdapSearchScope;
) -> Result<(), LdapError> { use ldap3_proto::proto::LdapDerefAliases;
debug!("{:?}", search_request.sr);
let SearchRequest {
sr,
msgid,
ctrl,
client,
} = search_request;
let (entries, result, ctrl) = match client.search(sr, ctrl).await { use super::*;
Ok(data) => data, use crate::filter::{search_filter_and, search_filter_eq, search_filter_or};
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. fn search_query(dn: &str, filter: LdapFilter) -> LdapSearchRequest {
return Ok(()); 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); fn sample_config(backends: &[&str]) -> Config {
w.send(LdapMsg { let mut c = Config::default();
msgid, for backend in backends {
op: LdapOp::SearchResultDone(result), c.mapping.push(Mapping {
ctrl, from: backend.to_string(),
}) to: "example.com".to_string(),
.await backend: format!("/tmp/sample-mapping-{backend}.sock"),
.map_err(|err| { user: "admin".to_string(),
error!("Unable to send response: {err}"); password: "adminadmin".to_string(),
LdapError::Transport });
})?; }
c
}
// No state change #[test]
Ok(()) 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);
}
} }

27
src/prelude.rs Normal file
View file

@ -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;

View file

@ -1,5 +1,6 @@
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::net::{TcpStream, UnixStream}; use tokio::net::{TcpStream, UnixStream};
use tokio_listener::Connection;
use std::io::Result; use std::io::Result;
use std::marker::Unpin; use std::marker::Unpin;
@ -9,6 +10,7 @@ use std::task::{Context, Poll};
pub enum AbstractStream { pub enum AbstractStream {
Tcp(TcpStream), Tcp(TcpStream),
Uds(UnixStream), Uds(UnixStream),
Listener(Connection),
} }
impl From<TcpStream> for AbstractStream { impl From<TcpStream> for AbstractStream {
@ -23,6 +25,12 @@ impl From<UnixStream> for AbstractStream {
} }
} }
impl From<Connection> for AbstractStream {
fn from(stream: Connection) -> Self {
Self::Listener(stream)
}
}
impl Unpin for AbstractStream {} impl Unpin for AbstractStream {}
impl AsyncRead for AbstractStream { impl AsyncRead for AbstractStream {
@ -34,6 +42,7 @@ impl AsyncRead for AbstractStream {
match &mut *self { match &mut *self {
Self::Tcp(stream) => pin!(stream).poll_read(cx, buf), Self::Tcp(stream) => pin!(stream).poll_read(cx, buf),
Self::Uds(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 { match &mut *self {
Self::Tcp(stream) => pin!(stream).poll_write(cx, buf), Self::Tcp(stream) => pin!(stream).poll_write(cx, buf),
Self::Uds(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 { match &mut *self {
Self::Tcp(stream) => pin!(stream).poll_flush(cx), Self::Tcp(stream) => pin!(stream).poll_flush(cx),
Self::Uds(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 { match &mut *self {
Self::Tcp(stream) => pin!(stream).poll_shutdown(cx), Self::Tcp(stream) => pin!(stream).poll_shutdown(cx),
Self::Uds(stream) => pin!(stream).poll_shutdown(cx), Self::Uds(stream) => pin!(stream).poll_shutdown(cx),
Self::Listener(stream) => pin!(stream).poll_shutdown(cx),
} }
} }
} }