ldap_test_server/
builder.rs

1use crate::LdapServerConn;
2use rand::Rng;
3use random_port::{PortPicker, Protocol};
4use rcgen::{CertificateParams, KeyPair, SanType};
5use std::net::{IpAddr, ToSocketAddrs};
6use std::path::{Path, PathBuf};
7use std::process::Stdio;
8use std::str::FromStr;
9use std::time::Duration;
10use tempfile::tempdir;
11use tokio::fs;
12use tokio::io::AsyncBufReadExt;
13use tokio::net::TcpStream;
14use tokio::process::Command;
15use tokio::time::{sleep, timeout};
16use tracing::debug;
17use url::Url;
18
19const INIT_LDIF: &str = include_str!("init.ldif");
20const POSSIBLE_SCHEMA_DIR: &[&str] = &[
21    "/etc/ldap/schema",
22    "/usr/local/etc/openldap/schema",
23    "/etc/openldap/schema/",
24];
25
26#[derive(Debug)]
27enum LdapFile {
28    SystemSchema(PathBuf),
29    File { template: bool, file: PathBuf },
30    Text { template: bool, content: String },
31}
32
33/// LDAP server builder
34#[derive(Debug)]
35pub struct LdapServerBuilder {
36    base_dn: String,
37    root_dn: String,
38    root_pw: String,
39    bind_addr: Option<String>,
40    port: Option<u16>,
41    ssl_port: Option<u16>,
42    includes: Vec<(u8, LdapFile)>,
43    ssl_cert_key: Option<(String, String)>,
44}
45
46impl LdapServerBuilder {
47    /// Init empty builder
48    pub fn empty(
49        base_dn: impl Into<String>,
50        root_dn: impl Into<String>,
51        root_pw: impl Into<String>,
52    ) -> Self {
53        let base_dn = base_dn.into();
54        let root_dn = root_dn.into();
55        let root_pw = root_pw.into();
56
57        Self {
58            base_dn,
59            root_dn,
60            root_pw,
61            bind_addr: None,
62            port: None,
63            ssl_port: None,
64            includes: vec![],
65            ssl_cert_key: None,
66        }
67    }
68
69    /// Init builder with simple database
70    pub fn new(base_dn: &str) -> Self {
71        let root_dn = format!("cn=admin,{base_dn}");
72        let root_pw = "secret".to_string();
73        LdapServerBuilder::empty(base_dn, root_dn, root_pw).add_template(0, INIT_LDIF)
74    }
75
76    /// Use existing ssl certificate and key PEM
77    pub fn ssl_certificates(mut self, certificate: String, key: String) -> Self {
78        self.ssl_cert_key = Some((certificate, key));
79        self
80    }
81
82    /// Listen address
83    pub fn bind_addr(mut self, bind_addr: &str) -> Self {
84        self.bind_addr = Some(bind_addr.to_string());
85        self
86    }
87
88    /// Listen port
89    pub fn port(mut self, port: u16) -> Self {
90        self.port = Some(port);
91        self
92    }
93
94    /// Listen SSL port
95    pub fn ssl_port(mut self, port: u16) -> Self {
96        self.ssl_port = Some(port);
97        self
98    }
99
100    /// Add system LDIF from schema dir installed by slapd (usually in /etc/ldap/schema directory)
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use ldap_test_server::LdapServerBuilder;
106    ///
107    /// # #[tokio::main(flavor = "current_thread")]
108    /// # async fn main() {
109    /// let server = LdapServerBuilder::new("dc=planetexpress,dc=com")
110    ///     .add_system_file(0, "collective.ldif")
111    ///     .run().await;
112    /// # }
113    /// ```
114    pub fn add_system_file<P: AsRef<Path>>(mut self, dbnum: u8, file: P) -> Self {
115        self.includes
116            .push((dbnum, LdapFile::SystemSchema(file.as_ref().to_path_buf())));
117        self
118    }
119
120    /// Add LDIF file with text content
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use ldap_test_server::LdapServerBuilder;
126    ///
127    /// # #[tokio::main(flavor = "current_thread")]
128    /// # async fn main() {
129    /// let server = LdapServerBuilder::new("dc=planetexpress,dc=com")
130    ///     .add(0, "dn: cn=user,cn=schema,cn=config
131    /// objectClass: olcSchemaConfig
132    /// cn: user
133    /// olcAttributeTypes: ( 1.2.840.113556.4.221
134    ///   NAME 'sAMAccountName'
135    ///   SYNTAX '1.3.6.1.4.1.1466.115.121.1.15'
136    ///   EQUALITY caseIgnoreMatch
137    ///   SUBSTR caseIgnoreSubstringsMatch
138    ///   SINGLE-VALUE )
139    /// olcObjectClasses: ( 1.2.840.113556.1.5.9
140    ///   NAME 'user'
141    ///   SUP top
142    ///   AUXILIARY
143    ///   MAY ( sAMAccountName ))")
144    ///     .run().await;
145    /// # }
146    /// ```
147    pub fn add(mut self, dbnum: u8, content: &str) -> Self {
148        self.includes.push((
149            dbnum,
150            LdapFile::Text {
151                template: false,
152                content: content.to_string(),
153            },
154        ));
155        self
156    }
157
158    /// Add LDIF file
159    pub fn add_file<P: AsRef<Path>>(mut self, dbnum: u8, file: P) -> Self {
160        self.includes.push((
161            dbnum,
162            LdapFile::File {
163                template: true,
164                file: file.as_ref().to_path_buf(),
165            },
166        ));
167        self
168    }
169
170    /// Add LDIF file with text content as template
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use ldap_test_server::{LdapServerConn, LdapServerBuilder};
176    ///
177    /// # #[tokio::main(flavor = "current_thread")]
178    /// # async fn main() {
179    /// let server: LdapServerConn = LdapServerBuilder::empty("dc=planetexpress,dc=com", "cn=admin,dc=planetexpress,dc=com", "secret")
180    ///     .add_template(0, include_str!("init.ldif"))
181    ///     .run().await;
182    /// # }
183    /// ```
184    pub fn add_template(mut self, dbnum: u8, content: &str) -> Self {
185        self.includes.push((
186            dbnum,
187            LdapFile::Text {
188                template: true,
189                content: content.to_string(),
190            },
191        ));
192        self
193    }
194
195    /// Add LDIF file as template
196    pub fn add_template_file<P: AsRef<Path>>(mut self, dbnum: u8, file: P) -> Self {
197        self.includes.push((
198            dbnum,
199            LdapFile::File {
200                template: true,
201                file: file.as_ref().to_path_buf(),
202            },
203        ));
204        self
205    }
206
207    async fn build_config(
208        includes: Vec<(u8, LdapFile)>,
209        work_dir: &Path,
210        config_dir: &Path,
211        system_schema_dir: &Path,
212    ) {
213        fs::create_dir(&config_dir)
214            .await
215            .expect("cannot create config dir");
216
217        for (idx, (dbnum, include)) in includes.into_iter().enumerate() {
218            let file = match include {
219                LdapFile::SystemSchema(file) => system_schema_dir.join(file),
220                LdapFile::File {
221                    template: false,
222                    file,
223                } => file,
224                LdapFile::Text {
225                    template: false,
226                    content,
227                } => {
228                    let tmp_ldif = work_dir.join(format!("tmp_{idx}.ldif"));
229                    tokio::fs::write(&tmp_ldif, content).await.unwrap();
230                    tmp_ldif
231                }
232                LdapFile::File { template: true, .. } | LdapFile::Text { template: true, .. } => {
233                    panic!("Templates should be already built");
234                }
235            };
236
237            LdapServerBuilder::load_ldif(config_dir, dbnum, file).await;
238        }
239    }
240
241    async fn load_ldif(config_dir: &Path, dbnum: u8, file: PathBuf) {
242        debug!("slapadd dbnum: {dbnum} file: {}", file.display());
243
244        let db_number = dbnum.to_string();
245        // load slapd configuration
246        let output = Command::new("slapadd")
247            .arg("-F")
248            .arg(config_dir)
249            .arg("-n")
250            .arg(db_number)
251            .arg("-l")
252            .arg(&file)
253            .output()
254            .await
255            .expect("failed to execute slapadd");
256
257        if !output.status.success() {
258            panic!(
259                "slapadd command exited with error {}, stdout: {}, stderr: {} on file {}",
260                output.status,
261                String::from_utf8_lossy(&output.stdout),
262                String::from_utf8_lossy(&output.stderr),
263                file.display()
264            );
265        }
266    }
267
268    async fn build_templates(&mut self, system_schema_dir: &Path, work_dir: &Path) {
269        let schema_dir_url = Url::from_file_path(system_schema_dir).unwrap();
270        let work_dir_path = work_dir.display().to_string();
271
272        for (_, include) in &mut self.includes {
273            let content = match include {
274                LdapFile::File {
275                    template: true,
276                    file,
277                } => fs::read_to_string(file).await.unwrap(),
278                LdapFile::Text {
279                    template: true,
280                    content,
281                } => std::mem::take(content),
282                _ => continue,
283            };
284
285            let new_content = content
286                .replace("@SCHEMADIR@", schema_dir_url.as_ref())
287                .replace("@WORKDIR@", &work_dir_path)
288                .replace("@BASEDN@", &self.base_dn)
289                .replace("@ROOTDN@", &self.root_dn)
290                .replace("@ROOTPW@", &self.root_pw);
291
292            *include = LdapFile::Text {
293                template: false,
294                content: new_content,
295            };
296        }
297    }
298
299    /// Create database and run LDAP server
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// use ldap_test_server::{LdapServerConn, LdapServerBuilder};
305    ///
306    /// # #[tokio::main(flavor = "current_thread")]
307    /// # async fn main() {
308    /// let server: LdapServerConn = LdapServerBuilder::new("dc=planetexpress,dc=com")
309    ///     .run().await;
310    /// # }
311    /// ```
312    pub async fn run(mut self) -> LdapServerConn {
313        let schema_dir = find_slapd_schema_dir()
314            .await
315            .expect("no slapd schema directory found. Is openldap server installed?");
316        let host = self
317            .bind_addr
318            .clone()
319            .unwrap_or_else(|| "127.0.0.1".to_string());
320        let port_picker = PortPicker::new()
321            .host(host.clone())
322            .protocol(Protocol::Tcp)
323            .random(true);
324        let port = self.port.unwrap_or_else(|| {
325            port_picker.pick().unwrap_or_else(|_| {
326                let mut rng = rand::thread_rng();
327                rng.gen_range(15000..55000)
328            })
329        });
330
331        let ssl_port = self.ssl_port.unwrap_or_else(|| {
332            port_picker.pick().unwrap_or_else(|_| {
333                let mut rng = rand::thread_rng();
334                rng.gen_range(15000..55000)
335            })
336        });
337
338        let url = format!("ldap://{host}:{port}");
339        let ssl_url = format!("ldaps://{host}:{ssl_port}");
340        let dir = tempdir().unwrap();
341
342        let (ssl_cert_pem, ssl_key_pem) = if let Some(keys) = self.ssl_cert_key.clone() {
343            keys
344        } else {
345            let params = if let Ok(addr) = IpAddr::from_str(&host) {
346                let mut params = CertificateParams::new(vec![]).unwrap();
347                params.subject_alt_names.push(SanType::IpAddress(addr));
348                params
349            } else {
350                CertificateParams::new(vec![host.clone()]).unwrap()
351            };
352
353            let key_pair = KeyPair::generate().unwrap();
354            let cert = params.self_signed(&key_pair).unwrap();
355            let ssl_cert_pem = cert.pem();
356            let ssl_key_pem = key_pair.serialize_pem();
357            (ssl_cert_pem, ssl_key_pem)
358        };
359
360        let cert_pem = dir.path().join("cert.pem");
361        fs::write(&cert_pem, &ssl_cert_pem).await.unwrap();
362
363        let key_pem = dir.path().join("key.pem");
364        fs::write(&key_pem, &ssl_key_pem).await.unwrap();
365
366        self.build_templates(schema_dir, dir.path()).await;
367        let config_dir = dir.path().join("config");
368        LdapServerBuilder::build_config(self.includes, dir.path(), &config_dir, schema_dir).await;
369
370        let urls = format!("{url} {ssl_url}");
371        // launch slapd server
372        let mut server = Command::new("slapd")
373            .arg("-F")
374            .arg(&config_dir)
375            .arg("-d")
376            .arg("2048")
377            .arg("-h")
378            .arg(&urls)
379            .stderr(Stdio::piped())
380            .spawn()
381            .unwrap();
382
383        // wait until slapd server has started
384        let stderr = server.stderr.take().unwrap();
385        let mut lines = tokio::io::BufReader::new(stderr).lines();
386        let timeouted = timeout(Duration::from_secs(60), async {
387            while let Some(line) = lines.next_line().await.unwrap() {
388                debug!("slapd: {line}");
389                if line.ends_with("slapd starting") {
390                    return true;
391                }
392            }
393            false
394        })
395        .await;
396
397        if timeouted.is_err() || timeouted == Ok(false) {
398            let _ = server.kill().await;
399            panic!("Failed to start slapd server: timeout");
400        }
401
402        let timeouted = timeout(Duration::from_secs(60), async {
403            while !is_tcp_port_open(&host, port).await {
404                debug!("tcp port {port} is not open yet, waiting...");
405                sleep(Duration::from_micros(100)).await;
406            }
407        })
408        .await;
409
410        if timeouted.is_err() {
411            let _ = server.kill().await;
412            panic!("Failed to start slapd server, port {port} not open");
413        }
414
415        debug!("Started ldap server on {urls}");
416
417        LdapServerConn {
418            url,
419            host,
420            port,
421            ssl_url,
422            ssl_port,
423            ssl_cert_pem,
424            dir,
425            base_dn: self.base_dn,
426            root_dn: self.root_dn,
427            root_pw: self.root_pw,
428            server,
429        }
430    }
431}
432
433async fn find_slapd_schema_dir() -> Option<&'static Path> {
434    for dir in POSSIBLE_SCHEMA_DIR {
435        let dir: &Path = dir.as_ref();
436        if tokio::fs::metadata(dir)
437            .await
438            .map(|m| m.is_dir())
439            .unwrap_or(false)
440        {
441            return Some(dir);
442        }
443    }
444    None
445}
446
447async fn is_tcp_port_open(host: &str, port: u16) -> bool {
448    let addr = (host, port).to_socket_addrs().unwrap().next().unwrap();
449    let Ok(sock) = timeout(Duration::from_secs(1), TcpStream::connect(&addr)).await else {
450        return false;
451    };
452    sock.is_ok()
453}