refactor: EVERYTHING
This commit is contained in:
parent
c746e594ca
commit
7ab0898413
17 changed files with 1410 additions and 728 deletions
|
|
@ -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
16
config.toml
Normal 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
343
src/backend.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/client.rs
196
src/client.rs
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
21
src/dn.rs
21
src/dn.rs
|
|
@ -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
6
src/error.rs
Normal 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
241
src/filter/mail.rs
Normal 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
19
src/filter/mod.rs
Normal 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())
|
||||||
|
}
|
||||||
184
src/handler/client_process.rs
Normal file
184
src/handler/client_process.rs
Normal 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
3
src/handler/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// pub mod bound;
|
||||||
|
pub mod client_process;
|
||||||
|
// pub mod unbound;
|
||||||
179
src/main.rs
179
src/main.rs
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
262
src/op/bind.rs
262
src/op/bind.rs
|
|
@ -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 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
.send(LdapMsg {
|
||||||
msgid,
|
msgid,
|
||||||
op,
|
op,
|
||||||
ctrl: vec![],
|
ctrl: vec![],
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
.map_err(|err| {
|
|
||||||
error!("Unable to send response: {err}");
|
|
||||||
LdapError::Transport
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
587
src/op/search.rs
587
src/op/search.rs
|
|
@ -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"
|
|
||||||
}
|
|
||||||
Self::NoMailFilter => "No mail filter found in search request",
|
|
||||||
Self::InvalidFilter => "No AND/OR/EQUALITY filter found in search request",
|
|
||||||
};
|
|
||||||
write!(f, "{}", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for MailDomainError {}
|
|
||||||
|
|
||||||
pub fn mail_username_domain_from_equality_filter(
|
|
||||||
attr: &str,
|
|
||||||
value: &str,
|
|
||||||
) -> Result<Option<(String, String)>, MailDomainError> {
|
|
||||||
if attr != "mail" {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut parts = value.split('@');
|
|
||||||
let username = parts.next().unwrap();
|
|
||||||
let Some(domain) = parts.next() else {
|
|
||||||
debug!("Not a valid username@domain email: {value}");
|
|
||||||
return Err(MailDomainError::InvalidMail);
|
|
||||||
};
|
|
||||||
|
|
||||||
if parts.next().is_some() {
|
|
||||||
debug!("Too many parts in mail address");
|
|
||||||
return Err(MailDomainError::InvalidMail);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(Some((username.to_string(), domain.to_string())));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mail_username_domain_from_filters(
|
|
||||||
filters: &[LdapFilter],
|
|
||||||
) -> Result<(String, String), MailDomainError> {
|
|
||||||
for filter in filters {
|
|
||||||
match filter {
|
|
||||||
LdapFilter::Equality(attr, value) => {
|
|
||||||
if let Some(found) = mail_username_domain_from_equality_filter(attr, value)? {
|
|
||||||
return Ok(found);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("No mail filter found");
|
|
||||||
return Err(MailDomainError::NoMailFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mail_username_domain_from_filter(
|
|
||||||
filter: &LdapFilter,
|
|
||||||
) -> Result<(String, String), MailDomainError> {
|
|
||||||
match filter {
|
|
||||||
LdapFilter::And(filters) => mail_username_domain_from_filters(&filters),
|
|
||||||
LdapFilter::Or(filters) => mail_username_domain_from_filters(&filters),
|
|
||||||
LdapFilter::Equality(attr, value) => {
|
|
||||||
if let Some(domain) = mail_username_domain_from_equality_filter(attr, value)? {
|
|
||||||
Ok(domain)
|
|
||||||
} else {
|
|
||||||
debug!("No mail filter found");
|
|
||||||
Err(MailDomainError::NoMailFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Err(MailDomainError::InvalidFilter),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn search_by_email_attribute<W: AsyncWrite + Unpin>(
|
|
||||||
w: &mut FramedWrite<W, LdapCodec>,
|
|
||||||
mut sr: LdapSearchRequest,
|
|
||||||
msgid: i32,
|
msgid: i32,
|
||||||
ctrl: Vec<LdapControl>,
|
ctrl: Vec<LdapControl>,
|
||||||
config: Arc<Config>,
|
) -> Result<(), UnrecoverableError> {
|
||||||
) -> Result<(), LdapError> {
|
let resp_msg = LdapMsg {
|
||||||
debug!("unbound search by email attribute {:?}", sr);
|
msgid,
|
||||||
|
op: LdapOp::SearchResultDone(LdapResult {
|
||||||
|
code: self.code(),
|
||||||
|
matcheddn: String::new(),
|
||||||
|
message: self.message(),
|
||||||
|
referral: vec![],
|
||||||
|
}),
|
||||||
|
ctrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.send(resp_msg).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BackendError> for SearchError {
|
||||||
|
fn from(e: BackendError) -> Self {
|
||||||
|
Self::Backend(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<InvalidDnError> for SearchError {
|
||||||
|
fn from(e: InvalidDnError) -> Self {
|
||||||
|
Self::InvalidDn(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MailDomainError> for SearchError {
|
||||||
|
fn from(e: MailDomainError) -> Self {
|
||||||
|
Self::MailDomain(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<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
27
src/prelude.rs
Normal 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;
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue