LDAP
Crate hopf-ldap: async LDAPv3 client (RFC 4511) and LdapCredentialStore — Gumdrop ldap / LDAPRealm port. BER wire codec lives in hopf_ldap::asn1; the store implements CredentialStore without putting LDAP types into hopf-auth.
Contents
Standards
| Topic | Reference |
|---|---|
| LDAPv3 | RFC 4511 |
| Authentication methods / LDAPS | RFC 4513 |
| String representation of filters | RFC 4515 |
| Content Synchronization (syncrepl), client side | RFC 4533 |
| BER (definite length) | ITU-T X.690 (LDAP subset) |
Architecture
Mail / HTTP / FTP
│
▼
hopf-auth::CredentialStore ←── PasswordStore (demo)
│ ←── hopf-ldap::LdapCredentialStore
│ ←── hopf-auth::PamCredentialStore (feature pam)
▼
hopf-ldap::LdapClient → ProtocolHandler on Runtime
│
▼
hopf-ldap::asn1 (BER)
CredentialStore stays protocol-agnostic (same as Gumdrop Realm). LDAP lives only in hopf-ldap; system PAM is optional feature pam on hopf-auth.
BER codec
Module hopf_ldap::asn1 ports Gumdrop’s definite-length BER stack: BerEncoder, BerDecoder (streaming receive / next), Asn1Element, tag helpers. Indefinite length is rejected. This is LDAP-oriented BER, not full DER/CER.
Client
LdapClient dials via Runtime::connect / TcpConnectorConfig. On ready it delivers an LdapSession:
bind/bind_anonymous(simple auth)search(subtree and other scopes; RFC 4515 filter string → BER)unbind(APPLICATION 2)
use hopf_ldap::{LdapClient, LdapClientConfig};
let cfg = LdapClientConfig::new("ldap.example.com", 389);
LdapClient::connect(&rt, cfg, |ready| {
if let Ok(session) = ready {
session.bind_anonymous(|r| { let _ = r; });
}
})?;
LDAPS / STARTTLS
LDAPS (implicit TLS from dial, port 636 typical): LdapClientConfig::with_tls / LdapStoreConfig::with_ldaps. The handler waits for security_established before delivering the session.
STARTTLS (RFC 4511 §4.14): dial plaintext, then LdapSession::start_tls (or configure LdapStoreConfig::with_starttls so the store does it before bind). Uses Endpoint::start_client_tls after a successful ExtendedResponse for OID 1.3.6.1.4.1.1466.20037. LDAPS and STARTTLS are mutually exclusive on one dial.
Referral chase
LdapCredentialStore does not chase search/bind referrals by default (enable with with_chase_referrals(true); hop cap max_referral_hops, default 5). URLs are parsed per RFC 4516 (ldap:// / ldaps://); empty DN in the URL keeps the referring operation’s base DN (RFC 4511 §4.1.10). ldaps:// referrals need a TLS connector already configured on the store.
Content synchronization (RFC 4533)
A client that keeps a local copy of a directory subtree can resume incrementally after a disconnect instead of polling full search results. LdapSession::sync is an ordinary search carrying the Sync Request Control; its events are the server's entries (each tagged with a Sync State Control), cookie updates and phase markers, delivered on the reactor in the order sent. SyncReplica applies them.
let mut replica = SyncReplica::new(); // cookie() is None: an initial sync
replica.begin_refresh();
let mut request = SyncRequest::new(SyncMode::RefreshOnly);
if let Some(c) = replica.cookie() { request = request.with_cookie(c.to_vec()); }
session.sync(
SearchRequest::new("dc=example,dc=com", "(objectClass=*)"),
request,
move |event| { let _ = replica.apply(&event); }, // entries, cookies, phase markers
move |done| { /* Ok(SyncDone): replica.finish(&done); Err(_): failed or cancelled */ },
);
- Modes.
RefreshOnlyis a poll: the changes since the cookie, thenon_done.RefreshAndPersistsends the same refresh, marks its end with a Sync Info Message, then leaves the search open and streams changes as they happen.SyncHandle::cancelends it with an Abandon (RFC 4533 §3.7) and completeson_donewithLdapError::Cancelled. - Non-blocking. Nothing waits: a persistent sync is one outstanding search on the connection. Callbacks run on the reactor and must not block; other operations on the same session proceed while it is open.
- Cookies. Resume from the newest cookie received, from any source (an entry, a Sync Info message, the final Sync Done).
SyncReplica::cookietracks it.e-syncRefreshRequired(LdapResultCode::SyncRefreshRequired) is delivered asOkbecause it is an instruction:finishresets the replica, and the caller reloads with no cookie. Every other failing result code is anErr(SearchFailed). - Present and delete phases. Entries are keyed by
entryUUID, never DN.add/modifyreplace,deleteremoves, and a present entry (or a UUID in asyncIdSet) is unchanged and adopts any renamed DN. A refresh that ends on a present phase removes every held entry it did not confirm, because the server says nothing about entries that are gone; a refresh that ends on a delete phase removes only what it names. All three server styles of RFC 4533 §3.3.2 converge. - Errors. An unreadable Sync Info Message ends the operation with
LdapError::Protocoland abandons the search, because carrying on could silently lose a cookie or a phase boundary. An entry with no readable Sync State Control makesSyncReplica::applyreturnReplicaError::MissingSyncStaterather than guess its identity. - Controls. The message layer now carries LDAP controls both ways (
Control,encode_search_request_with_controls) and understands IntermediateResponse and Abandon, so other controls can be layered on the same plumbing. - Not implemented. The server side of RFC 4533, and the search-time interactions with other controls of section 5.
LdapCredentialStore
Search-then-bind (Gumdrop LDAPRealm.passwordMatch):
- Service bind (
bind_dn/bind_password) or anonymous - Search
base_dnwithuser_filter({0}= RFC 4515–escaped username) - Bind as discovered user DN + candidate password
CredentialStore method | LDAP store |
|---|---|
supported_mechanisms | PLAIN, LOGIN |
password_match | search-then-bind |
| digest / SCRAM / CRAM / plaintext / bearer | unsupported |
Reactor rule: password_match blocks on a Condvar + timeout completed from LDAP callbacks. Call it only from a storage/worker pool — never on a reactor thread.
Wiring mail services
Same with_store surface as PasswordStore:
use hopf_ldap::{LdapCredentialStore, LdapStoreConfig};
use hopf_smtp::{SmtpConfig, SmtpService};
let ldap = Arc::new(LdapCredentialStore::new(
LdapStoreConfig::new("ldap.example.com", "dc=example,dc=com", Arc::clone(&rt))
.with_bind("cn=admin,dc=example,dc=com", "secret")
.with_user_filter("(uid={0})"),
));
let config = SmtpConfig::new(listen, "mail.example.com")
.with_store(ldap);
Example binary: examples/smtp-ldap (cargo run -p smtp-ldap -- …). POP3/IMAP use the same Arc<dyn CredentialStore>.
Limitations
- No modify/add/delete/compare admin API (deferred)
- No SASL bind to the directory server (store uses simple bind only)
- No role /
memberOfAPI — Gumdrop used that for servlet + FTP/mail admin; Hopf has no consumers yet (quota uses a separate lookup callback if needed) - Hostname dial uses blocking
ToSocketAddrs; prefer a resolvedSocketAddrwhen possible - Referral chase does not rewrite service credentials per URL; same bind DN/password is reused