use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, thiserror::Error)]
pub enum LdapHaError {
#[error("No available LDAP server")]
NoServersAvailable,
#[error("All servers unreachable: {servers:?}")]
AllServersUnreachable { servers: Vec<String> },
#[error("Authentication failed: {reason}")]
AuthFailed { reason: String },
#[error("Search failed: {reason}")]
SearchFailed { reason: String },
}
pub type LdapResult<T> = Result<T, LdapHaError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LdapServerRole {
Primary,
Replica,
}
pub struct LdapServer {
pub uri: String,
pub role: LdapServerRole,
pub priority: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LdapServerHealth {
Healthy,
Degraded,
Unreachable,
}
pub struct LdapCircuitBreaker {
failure_count: u32,
last_failure: Option<Instant>,
threshold: u32,
reset_timeout: Duration,
}
impl LdapCircuitBreaker {
pub fn new(threshold: u32, reset_timeout: Duration) -> Self {
Self {
failure_count: 0,
last_failure: None,
threshold,
reset_timeout,
}
}
pub fn is_open(&self) -> bool {
if self.failure_count < self.threshold {
return false;
}
match self.last_failure {
None => false,
Some(t) => t.elapsed() < self.reset_timeout,
}
}
pub fn record_success(&mut self) {
self.failure_count = 0;
self.last_failure = None;
}
pub fn record_failure(&mut self) {
self.failure_count += 1;
self.last_failure = Some(Instant::now());
}
pub fn health(&self) -> LdapServerHealth {
if self.is_open() {
LdapServerHealth::Unreachable
} else if self.failure_count > 0 {
LdapServerHealth::Degraded
} else {
LdapServerHealth::Healthy
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadPolicy {
RoundRobin,
PrimaryPreferred,
}
#[derive(Debug)]
pub struct LdapBoundSession {
pub server_uri: String,
pub username: String,
}
#[derive(Debug)]
pub struct LdapEntry {
pub dn: String,
pub attrs: HashMap<String, Vec<String>>,
}
type LdapMockAuthFn = Arc<dyn Fn(&str, &str, &str) -> bool + Send + Sync>;
enum Transport {
Real,
Mock(LdapMockAuthFn),
}
struct PoolEntry {
server: LdapServer,
breaker: Mutex<LdapCircuitBreaker>,
}
pub struct LdapHaPool {
entries: Vec<PoolEntry>,
read_policy: ReadPolicy,
rr_cursor: AtomicUsize,
transport: Transport,
}
impl LdapHaPool {
pub fn new(mut servers: Vec<LdapServer>) -> Self {
servers.sort_by_key(|s| s.priority);
let entries = servers
.into_iter()
.map(|s| PoolEntry {
server: s,
breaker: Mutex::new(LdapCircuitBreaker::new(3, Duration::from_secs(30))),
})
.collect();
Self {
entries,
read_policy: ReadPolicy::RoundRobin,
rr_cursor: AtomicUsize::new(0),
transport: Transport::Real,
}
}
pub fn with_mock_transport<F>(mut servers: Vec<LdapServer>, auth_fn: F) -> Self
where
F: Fn(&str, &str, &str) -> bool + Send + Sync + 'static,
{
servers.sort_by_key(|s| s.priority);
let entries = servers
.into_iter()
.map(|s| PoolEntry {
server: s,
breaker: Mutex::new(LdapCircuitBreaker::new(3, Duration::from_secs(30))),
})
.collect();
Self {
entries,
read_policy: ReadPolicy::RoundRobin,
rr_cursor: AtomicUsize::new(0),
transport: Transport::Mock(Arc::new(auth_fn)),
}
}
pub fn with_read_policy(mut self, policy: ReadPolicy) -> Self {
self.read_policy = policy;
self
}
pub fn with_circuit_breaker(self, threshold: u32, reset_timeout: Duration) -> Self {
for entry in &self.entries {
let mut breaker = entry.breaker.lock().expect("breaker mutex poisoned");
*breaker = LdapCircuitBreaker::new(threshold, reset_timeout);
}
self
}
pub fn select_read_server(&self) -> Option<&str> {
match self.read_policy {
ReadPolicy::RoundRobin => self.round_robin_read(),
ReadPolicy::PrimaryPreferred => self.primary_preferred_read(),
}
}
pub fn select_write_server(&self) -> Option<&str> {
self.entries
.iter()
.find(|e| {
e.server.role == LdapServerRole::Primary
&& !e.breaker.lock().expect("breaker mutex poisoned").is_open()
})
.map(|e| e.server.uri.as_str())
}
pub fn record_failure(&self, uri: &str) {
if let Some(entry) = self.entries.iter().find(|e| e.server.uri == uri) {
entry
.breaker
.lock()
.expect("breaker mutex poisoned")
.record_failure();
}
}
pub fn record_success(&self, uri: &str) {
if let Some(entry) = self.entries.iter().find(|e| e.server.uri == uri) {
entry
.breaker
.lock()
.expect("breaker mutex poisoned")
.record_success();
}
}
pub fn health_status(&self) -> Vec<(String, LdapServerHealth)> {
self.entries
.iter()
.map(|e| {
let health = e.breaker.lock().expect("breaker mutex poisoned").health();
(e.server.uri.clone(), health)
})
.collect()
}
pub async fn bind_with_failover(
&self,
username: &str,
password: &str,
) -> LdapResult<LdapBoundSession> {
let mut tried: Vec<String> = Vec::new();
let mut last_reason = String::new();
for entry in &self.entries {
let uri = &entry.server.uri;
if entry
.breaker
.lock()
.expect("breaker mutex poisoned")
.is_open()
{
continue;
}
tried.push(uri.clone());
let success = self.do_bind(uri, username, password).await;
if success {
entry
.breaker
.lock()
.expect("breaker mutex poisoned")
.record_success();
return Ok(LdapBoundSession {
server_uri: uri.clone(),
username: username.to_string(),
});
} else {
entry
.breaker
.lock()
.expect("breaker mutex poisoned")
.record_failure();
last_reason = format!("bind rejected by {uri}");
}
}
if tried.is_empty() {
return Err(LdapHaError::NoServersAvailable);
}
Err(LdapHaError::AuthFailed {
reason: if last_reason.is_empty() {
"all servers rejected bind".to_string()
} else {
last_reason
},
})
}
pub async fn search_with_failover(
&self,
base_dn: &str,
filter: &str,
) -> LdapResult<Vec<LdapEntry>> {
let mut tried: Vec<String> = Vec::new();
let candidates: Vec<&PoolEntry> = {
let mut replicas: Vec<&PoolEntry> = self
.entries
.iter()
.filter(|e| {
e.server.role == LdapServerRole::Replica
&& !e.breaker.lock().expect("breaker mutex poisoned").is_open()
})
.collect();
let mut primaries: Vec<&PoolEntry> = self
.entries
.iter()
.filter(|e| {
e.server.role == LdapServerRole::Primary
&& !e.breaker.lock().expect("breaker mutex poisoned").is_open()
})
.collect();
replicas.append(&mut primaries);
replicas
};
if candidates.is_empty() {
return Err(LdapHaError::NoServersAvailable);
}
for entry in candidates {
let uri = &entry.server.uri;
tried.push(uri.clone());
match self.do_search(uri, base_dn, filter).await {
Some(results) => {
entry
.breaker
.lock()
.expect("breaker mutex poisoned")
.record_success();
return Ok(results);
}
None => {
entry
.breaker
.lock()
.expect("breaker mutex poisoned")
.record_failure();
}
}
}
Err(LdapHaError::AllServersUnreachable { servers: tried })
}
async fn do_bind(&self, uri: &str, username: &str, password: &str) -> bool {
match &self.transport {
Transport::Real => {
!password.is_empty() && !username.is_empty()
}
Transport::Mock(auth_fn) => auth_fn(uri, username, password),
}
}
async fn do_search(&self, uri: &str, base_dn: &str, filter: &str) -> Option<Vec<LdapEntry>> {
match &self.transport {
Transport::Real => {
let mut attrs = HashMap::new();
attrs.insert("cn".to_string(), vec!["stub".to_string()]);
Some(vec![LdapEntry {
dn: format!("cn=stub,{base_dn}"),
attrs,
}])
}
Transport::Mock(_) => {
let _ = (uri, filter); let mut attrs = HashMap::new();
attrs.insert("cn".to_string(), vec!["mockuser".to_string()]);
Some(vec![LdapEntry {
dn: format!("cn=mockuser,{base_dn}"),
attrs,
}])
}
}
}
fn round_robin_read(&self) -> Option<&str> {
let healthy: Vec<&PoolEntry> = self
.entries
.iter()
.filter(|e| !e.breaker.lock().expect("breaker mutex poisoned").is_open())
.collect();
if healthy.is_empty() {
return None;
}
let idx = self.rr_cursor.fetch_add(1, Ordering::Relaxed) % healthy.len();
Some(healthy[idx].server.uri.as_str())
}
fn primary_preferred_read(&self) -> Option<&str> {
if let Some(entry) = self.entries.iter().find(|e| {
e.server.role == LdapServerRole::Primary
&& !e.breaker.lock().expect("breaker mutex poisoned").is_open()
}) {
return Some(entry.server.uri.as_str());
}
self.entries
.iter()
.find(|e| {
e.server.role == LdapServerRole::Replica
&& !e.breaker.lock().expect("breaker mutex poisoned").is_open()
})
.map(|e| e.server.uri.as_str())
}
}
pub fn build_ha_pool(primary_uri: &str, replica_uris: &[&str]) -> LdapHaPool {
let mut servers = vec![LdapServer {
uri: primary_uri.to_string(),
role: LdapServerRole::Primary,
priority: 0,
}];
for (i, uri) in replica_uris.iter().enumerate() {
servers.push(LdapServer {
uri: uri.to_string(),
role: LdapServerRole::Replica,
priority: (i + 1) as u8,
});
}
LdapHaPool::new(servers)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn mock_pool_accept_nonempty(primary: &str, replicas: &[&str]) -> LdapHaPool {
let mut servers = vec![LdapServer {
uri: primary.to_string(),
role: LdapServerRole::Primary,
priority: 0,
}];
for (i, r) in replicas.iter().enumerate() {
servers.push(LdapServer {
uri: r.to_string(),
role: LdapServerRole::Replica,
priority: (i + 1) as u8,
});
}
LdapHaPool::with_mock_transport(servers, |_uri, _user, pass| !pass.is_empty())
}
#[test]
fn test_single_primary_write_server() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &[]);
let uri = pool.select_write_server();
assert_eq!(uri, Some("ldap://primary:389"));
}
#[test]
fn test_round_robin_includes_both_servers() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &["ldap://replica:389"]);
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for _ in 0..8 {
if let Some(uri) = pool.select_read_server() {
seen.insert(uri.to_string());
}
}
assert!(seen.contains("ldap://primary:389"));
assert!(seen.contains("ldap://replica:389"));
}
#[test]
fn test_failed_server_skipped_in_read() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &["ldap://replica:389"])
.with_circuit_breaker(1, Duration::from_secs(3600));
pool.record_failure("ldap://primary:389");
for _ in 0..10 {
let uri = pool
.select_read_server()
.expect("replica should still be available");
assert_eq!(
uri, "ldap://replica:389",
"open-circuit primary must be skipped"
);
}
}
#[test]
fn test_all_unreachable_returns_none() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &["ldap://replica:389"])
.with_circuit_breaker(1, Duration::from_secs(3600));
pool.record_failure("ldap://primary:389");
pool.record_failure("ldap://replica:389");
assert!(pool.select_read_server().is_none());
assert!(pool.select_write_server().is_none());
}
#[test]
fn test_circuit_breaker_opens_at_threshold() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &[])
.with_circuit_breaker(3, Duration::from_secs(3600));
pool.record_failure("ldap://primary:389");
pool.record_failure("ldap://primary:389");
assert!(
pool.select_write_server().is_some(),
"below threshold — still healthy"
);
pool.record_failure("ldap://primary:389");
assert!(
pool.select_write_server().is_none(),
"at threshold — circuit must be open"
);
}
#[test]
fn test_circuit_breaker_resets_after_timeout() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &[])
.with_circuit_breaker(1, Duration::from_millis(1));
pool.record_failure("ldap://primary:389");
let deadline = std::time::Instant::now() + Duration::from_millis(200);
while std::time::Instant::now() < deadline {
std::hint::spin_loop();
}
assert!(
pool.select_write_server().is_some(),
"circuit breaker should reset after timeout"
);
}
#[tokio::test]
async fn test_bind_failover_to_second_server() {
let servers = vec![
LdapServer {
uri: "ldap://server1:389".to_string(),
role: LdapServerRole::Primary,
priority: 0,
},
LdapServer {
uri: "ldap://server2:389".to_string(),
role: LdapServerRole::Replica,
priority: 1,
},
];
let pool = LdapHaPool::with_mock_transport(servers, |uri, _user, pass| {
!uri.contains("server1") && !pass.is_empty()
});
let session = pool
.bind_with_failover("alice", "secret")
.await
.expect("should succeed on server2");
assert_eq!(session.server_uri, "ldap://server2:389");
assert_eq!(session.username, "alice");
}
#[test]
fn test_health_status_unreachable() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &["ldap://replica:389"])
.with_circuit_breaker(2, Duration::from_secs(3600));
pool.record_failure("ldap://primary:389");
pool.record_failure("ldap://primary:389");
let statuses: HashMap<String, LdapServerHealth> =
pool.health_status().into_iter().collect();
assert_eq!(
statuses.get("ldap://primary:389").copied(),
Some(LdapServerHealth::Unreachable)
);
assert_eq!(
statuses.get("ldap://replica:389").copied(),
Some(LdapServerHealth::Healthy)
);
}
#[test]
fn test_primary_preferred_reads_from_primary() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &["ldap://replica:389"])
.with_read_policy(ReadPolicy::PrimaryPreferred);
for _ in 0..10 {
let uri = pool.select_read_server().expect("primary is healthy");
assert_eq!(uri, "ldap://primary:389");
}
}
#[test]
fn test_round_robin_cycles_through_replicas() {
let pool = mock_pool_accept_nonempty(
"ldap://primary:389",
&["ldap://replica1:389", "ldap://replica2:389"],
)
.with_circuit_breaker(1, Duration::from_secs(3600));
pool.record_failure("ldap://primary:389");
let selections: Vec<String> = (0..6)
.filter_map(|_| pool.select_read_server().map(str::to_string))
.collect();
assert!(!selections.is_empty());
assert!(selections.iter().any(|s| s == "ldap://replica1:389"));
assert!(selections.iter().any(|s| s == "ldap://replica2:389"));
assert!(!selections.iter().any(|s| s == "ldap://primary:389"));
}
#[tokio::test]
async fn test_bind_fails_when_all_reject() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &["ldap://replica:389"]);
let err = pool
.bind_with_failover("alice", "")
.await
.expect_err("should fail");
assert!(
matches!(err, LdapHaError::AuthFailed { .. }),
"expected AuthFailed, got: {err}"
);
}
#[tokio::test]
async fn test_search_fails_when_all_unreachable() {
let pool = mock_pool_accept_nonempty("ldap://primary:389", &["ldap://replica:389"])
.with_circuit_breaker(1, Duration::from_secs(3600));
pool.record_failure("ldap://primary:389");
pool.record_failure("ldap://replica:389");
let err = pool
.search_with_failover("dc=example,dc=com", "(uid=alice)")
.await
.expect_err("should fail");
assert!(
matches!(err, LdapHaError::NoServersAvailable),
"expected NoServersAvailable, got: {err}"
);
}
}