use crate::{ domain::{ sql_tables::{ConfigLocation, PrivateKeyHash, PrivateKeyInfo, PrivateKeyLocation}, types::{AttributeName, UserId}, }, infra::{ cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts}, database_string::DatabaseUrl, }, }; use anyhow::{bail, Context, Result}; use figment::{ providers::{Env, Format, Serialized, Toml}, Figment, }; use figment_file_provider_adapter::FileAdapter; use lettre::message::Mailbox; use lldap_auth::opaque::{server::ServerSetup, KeyPair}; use secstr::SecUtf8; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)] #[builder(pattern = "owned")] pub struct MailOptions { #[builder(default = "false")] pub enable_password_reset: bool, #[builder(default)] pub from: Option, #[builder(default = "None")] pub reply_to: Option, #[builder(default = r#""localhost".to_string()"#)] pub server: String, #[builder(default = "587")] pub port: u16, #[builder(default)] pub user: String, #[builder(default = r#"SecUtf8::from("")"#)] pub password: SecUtf8, #[builder(default = "SmtpEncryption::Tls")] pub smtp_encryption: SmtpEncryption, /// Deprecated. #[builder(default = "None")] pub tls_required: Option, } impl std::default::Default for MailOptions { fn default() -> Self { MailOptionsBuilder::default().build().unwrap() } } #[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)] #[builder(pattern = "owned")] pub struct LdapsOptions { #[builder(default = "false")] pub enabled: bool, #[builder(default = "6360")] pub port: u16, #[builder(default = r#"String::from("cert.pem")"#)] pub cert_file: String, #[builder(default = r#"String::from("key.pem")"#)] pub key_file: String, } impl std::default::Default for LdapsOptions { fn default() -> Self { LdapsOptionsBuilder::default().build().unwrap() } } #[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)] #[builder(pattern = "owned", build_fn(name = "private_build"))] pub struct Configuration { #[builder(default = r#"String::from("0.0.0.0")"#)] pub ldap_host: String, #[builder(default = "3890")] pub ldap_port: u16, #[builder(default = r#"String::from("0.0.0.0")"#)] pub http_host: String, #[builder(default = "17170")] pub http_port: u16, #[builder(default = r#"SecUtf8::from("secretjwtsecret")"#)] pub jwt_secret: SecUtf8, #[builder(default = r#"String::from("dc=example,dc=com")"#)] pub ldap_base_dn: String, #[builder(default = r#"UserId::new("admin")"#)] pub ldap_user_dn: UserId, #[builder(default)] pub ldap_user_email: String, #[builder(default = r#"SecUtf8::from("password")"#)] pub ldap_user_pass: SecUtf8, #[builder(default = "false")] pub force_ldap_user_pass_reset: bool, #[builder(default = "false")] pub force_update_private_key: bool, #[builder(default = r#"DatabaseUrl::from("sqlite://users.db?mode=rwc")"#)] pub database_url: DatabaseUrl, #[builder(default)] pub ignored_user_attributes: Vec, #[builder(default)] pub ignored_group_attributes: Vec, #[builder(default = "false")] pub verbose: bool, #[builder(default = r#"String::from("server_key")"#)] pub key_file: String, // We want an Option to see whether there is a value or not, since the value is printed as // "***SECRET***". #[builder(default)] pub key_seed: Option, #[builder(default)] pub smtp_options: MailOptions, #[builder(default)] pub ldaps_options: LdapsOptions, #[builder(default = r#"Url::parse("http://localhost").unwrap()"#)] pub http_url: Url, #[serde(skip)] #[builder(field(private), default = "None")] server_setup: Option, } impl std::default::Default for Configuration { fn default() -> Self { ConfigurationBuilder::default().build().unwrap() } } impl ConfigurationBuilder { pub fn build(self) -> Result { let server_setup = get_server_setup( self.key_file.as_deref().unwrap_or("server_key"), self.key_seed .as_ref() .and_then(|o| o.as_ref()) .map(SecUtf8::unsecure) .unwrap_or_default(), PrivateKeyLocation::Default, )?; Ok(self.server_setup(Some(server_setup)).private_build()?) } #[cfg(test)] pub fn for_tests() -> Configuration { ConfigurationBuilder::default() .verbose(true) .server_setup(Some(ServerSetupConfig { server_setup: generate_random_private_key(), private_key_location: PrivateKeyLocation::Tests, })) .private_build() .unwrap() } } fn stable_hash(val: &[u8]) -> [u8; 32] { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(val); hasher.finalize().into() } impl Configuration { pub fn get_server_setup(&self) -> &ServerSetup { &self.server_setup.as_ref().unwrap().server_setup } pub fn get_server_keys(&self) -> &KeyPair { self.get_server_setup().keypair() } pub fn get_private_key_info(&self) -> PrivateKeyInfo { PrivateKeyInfo { private_key_hash: PrivateKeyHash(stable_hash(self.get_server_keys().private())), private_key_location: self .server_setup .as_ref() .unwrap() .private_key_location .clone(), } } } /// Returns whether the private key is entirely new. pub fn compare_private_key_hashes( previous_info: Option<&PrivateKeyInfo>, private_key_info: &PrivateKeyInfo, ) -> Result { match previous_info { None => Ok(true), Some(previous_info) => { if previous_info.private_key_hash == private_key_info.private_key_hash { Ok(false) } else { match ( &previous_info.private_key_location, &private_key_info.private_key_location, ) { ( PrivateKeyLocation::KeyFile(old_location, file_path), PrivateKeyLocation::KeySeed(new_location), ) => { bail!("The private key is configured to be generated from a seed (from {new_location:?}), but it used to come from the file {file_path:?} (defined in {old_location:?}). Did you just upgrade from <=v0.4 to >=v0.5? The key seed was not supported, revert to just using the file."); } (PrivateKeyLocation::Default, PrivateKeyLocation::KeySeed(new_location)) => { bail!("The private key is configured to be generated from a seed (from {new_location:?}), but it used to come from default key file \"server_key\". Did you just upgrade from <=v0.4 to >=v0.5? The key seed was not yet supported, revert to just using the file."); } ( PrivateKeyLocation::KeyFile(old_location, old_path), PrivateKeyLocation::KeyFile(new_location, new_path), ) => { if old_path == new_path { bail!("The contents of the private key file from {old_path:?} have changed. This usually means that the file was deleted and re-created. If using docker, make sure that the folder is made persistent (by mounting a volume or a directory). If you have several instances of LLDAP, make sure they share the same file (or switch to a key seed)."); } else { bail!("The private key file used to be {old_path:?} (defined in {old_location:?}), but now is {new_path:?} (defined in {new_location:?}. Make sure to copy the old file in the new location."); } } (old_location, new_location) => { bail!("The private key has changed. It used to come from {old_location:?}, but now it comes from {new_location:?}."); } } } } } } fn generate_random_private_key() -> ServerSetup { let mut rng = rand::rngs::OsRng; ServerSetup::new(&mut rng) } fn write_to_readonly_file(path: &std::path::Path, buffer: &[u8]) -> Result<()> { use std::{fs::File, io::Write}; assert!(!path.exists()); let mut file = File::create(path)?; let mut permissions = file.metadata()?.permissions(); permissions.set_readonly(true); if cfg!(unix) { use std::os::unix::fs::PermissionsExt; permissions.set_mode(0o400); } file.set_permissions(permissions)?; Ok(file.write_all(buffer)?) } #[derive(Debug, Clone)] pub struct ServerSetupConfig { server_setup: ServerSetup, private_key_location: PrivateKeyLocation, } #[derive(derive_more::From)] enum PrivateKeyLocationOrFigment { Figment(Figment), PrivateKeyLocation(PrivateKeyLocation), } impl PrivateKeyLocationOrFigment { fn for_key_seed(&self) -> PrivateKeyLocation { match self { PrivateKeyLocationOrFigment::Figment(config) => { match config.find_metadata("key_seed") { Some(figment::Metadata { source: Some(figment::Source::File(path)), .. }) => PrivateKeyLocation::KeySeed(ConfigLocation::ConfigFile( path.to_string_lossy().to_string(), )), Some(figment::Metadata { source: None, name, .. }) => PrivateKeyLocation::KeySeed(ConfigLocation::EnvironmentVariable( name.clone().to_string(), )), None | Some(figment::Metadata { source: Some(figment::Source::Code(_)), .. }) => PrivateKeyLocation::Default, other => panic!("Unexpected config location: {:?}", other), } } PrivateKeyLocationOrFigment::PrivateKeyLocation(PrivateKeyLocation::KeyFile( config_location, _, )) => { panic!("Unexpected location: {:?}", config_location) } PrivateKeyLocationOrFigment::PrivateKeyLocation(location) => location.clone(), } } fn for_key_file(&self, server_key_file: &str) -> PrivateKeyLocation { match self { PrivateKeyLocationOrFigment::Figment(config) => { match config.find_metadata("key_file") { Some(figment::Metadata { source: Some(figment::Source::File(path)), .. }) => PrivateKeyLocation::KeyFile( ConfigLocation::ConfigFile(path.to_string_lossy().to_string()), server_key_file.into(), ), Some(figment::Metadata { source: None, name, .. }) => PrivateKeyLocation::KeyFile( ConfigLocation::EnvironmentVariable(name.to_string()), server_key_file.into(), ), None | Some(figment::Metadata { source: Some(figment::Source::Code(_)), .. }) => PrivateKeyLocation::Default, other => panic!("Unexpected config location: {:?}", other), } } PrivateKeyLocationOrFigment::PrivateKeyLocation(PrivateKeyLocation::KeySeed(file)) => { panic!("Unexpected location: {:?}", file) } PrivateKeyLocationOrFigment::PrivateKeyLocation(location) => location.clone(), } } } fn get_server_setup>( file_path: &str, key_seed: &str, private_key_location: L, ) -> Result { let private_key_location = private_key_location.into(); use std::fs::read; let path = std::path::Path::new(file_path); if !key_seed.is_empty() { if path.exists() { bail!( "A key_seed was given, but a key file already exists at `{}`. Which one to use is ambiguous, aborting.\nNote: If you just migrated from <=v0.4 to >=v0.5, the previous version did not support key_seed, so it was falling back onto a key file. Remove the seed from the configuration.", file_path ); } else if file_path == "server_key" { eprintln!("WARNING: A key_seed was given, we will ignore the server_key and generate one from the seed!"); } else { println!("Generating the key from the key_seed"); } use rand::SeedableRng; let mut rng = rand_chacha::ChaCha20Rng::from_seed(stable_hash(key_seed.as_bytes())); Ok(ServerSetupConfig { server_setup: ServerSetup::new(&mut rng), private_key_location: private_key_location.for_key_seed(), }) } else if path.exists() { let bytes = read(file_path).context(format!("Could not read key file `{}`", file_path))?; Ok(ServerSetupConfig { server_setup: ServerSetup::deserialize(&bytes).context(format!( "while parsing the contents of the `{}` file", file_path ))?, private_key_location: private_key_location.for_key_file(file_path), }) } else { let server_setup = generate_random_private_key(); write_to_readonly_file(path, &server_setup.serialize()).context(format!( "Could not write the generated server setup to file `{}`", file_path, ))?; Ok(ServerSetupConfig { server_setup, private_key_location: private_key_location.for_key_file(file_path), }) } } pub trait ConfigOverrider { fn override_config(&self, config: &mut Configuration); } pub trait TopLevelCommandOpts { fn general_config(&self) -> &GeneralConfigOpts; } impl TopLevelCommandOpts for RunOpts { fn general_config(&self) -> &GeneralConfigOpts { &self.general_config } } impl TopLevelCommandOpts for TestEmailOpts { fn general_config(&self) -> &GeneralConfigOpts { &self.general_config } } impl ConfigOverrider for RunOpts { fn override_config(&self, config: &mut Configuration) { self.general_config.override_config(config); if let Some(path) = self.server_key_file.as_ref() { config.key_file = path.to_string(); } if let Some(seed) = self.server_key_seed.as_ref() { config.key_seed = Some(SecUtf8::from(seed)); } if let Some(port) = self.ldap_port { config.ldap_port = port; } if let Some(port) = self.http_port { config.http_port = port; } if let Some(url) = self.http_url.as_ref() { config.http_url = url.clone(); } if let Some(database_url) = self.database_url.as_ref() { config.database_url = database_url.clone(); } if let Some(force_ldap_user_pass_reset) = self.force_ldap_user_pass_reset { config.force_ldap_user_pass_reset = force_ldap_user_pass_reset; } if let Some(force_update_private_key) = self.force_update_private_key { config.force_update_private_key = force_update_private_key; } self.smtp_opts.override_config(config); self.ldaps_opts.override_config(config); } } impl ConfigOverrider for TestEmailOpts { fn override_config(&self, config: &mut Configuration) { self.general_config.override_config(config); self.smtp_opts.override_config(config); } } impl ConfigOverrider for LdapsOpts { fn override_config(&self, config: &mut Configuration) { if let Some(enabled) = self.ldaps_enabled { config.ldaps_options.enabled = enabled; } if let Some(port) = self.ldaps_port { config.ldaps_options.port = port; } if let Some(path) = self.ldaps_cert_file.as_ref() { config.ldaps_options.cert_file.clone_from(path); } if let Some(path) = self.ldaps_key_file.as_ref() { config.ldaps_options.key_file.clone_from(path); } } } impl ConfigOverrider for GeneralConfigOpts { fn override_config(&self, config: &mut Configuration) { if self.verbose { config.verbose = true; } } } impl ConfigOverrider for SmtpOpts { fn override_config(&self, config: &mut Configuration) { if let Some(from) = &self.smtp_from { config.smtp_options.from = Some(from.clone()); } if let Some(reply_to) = &self.smtp_reply_to { config.smtp_options.reply_to = Some(reply_to.clone()); } if let Some(server) = &self.smtp_server { config.smtp_options.server.clone_from(server); } if let Some(port) = self.smtp_port { config.smtp_options.port = port; } if let Some(user) = &self.smtp_user { config.smtp_options.user.clone_from(user); } if let Some(password) = &self.smtp_password { config.smtp_options.password = SecUtf8::from(password.clone()); } if let Some(smtp_encryption) = &self.smtp_encryption { config.smtp_options.smtp_encryption = smtp_encryption.clone(); } if let Some(tls_required) = self.smtp_tls_required { config.smtp_options.tls_required = Some(tls_required); } if let Some(enable_password_reset) = self.smtp_enable_password_reset { config.smtp_options.enable_password_reset = enable_password_reset; } } } pub fn init(overrides: C) -> Result where C: TopLevelCommandOpts + ConfigOverrider, { println!( "Loading configuration from {}", &overrides.general_config().config_file ); let ignore_keys = ["key_file", "cert_file"]; let figment_config = Figment::from(Serialized::defaults( ConfigurationBuilder::default().private_build().unwrap(), )) .merge( FileAdapter::wrap(Toml::file(&overrides.general_config().config_file)).ignore(&ignore_keys), ) .merge(FileAdapter::wrap(Env::prefixed("LLDAP_").split("__")).ignore(&ignore_keys)); let mut config: Configuration = figment_config.extract()?; overrides.override_config(&mut config); if config.verbose { println!("Configuration: {:#?}", &config); } config.server_setup = Some(get_server_setup( &config.key_file, config .key_seed .as_ref() .map(SecUtf8::unsecure) .unwrap_or_default(), figment_config, )?); if config.jwt_secret == SecUtf8::from("secretjwtsecret") { println!("WARNING: Default JWT secret used! This is highly unsafe and can allow attackers to log in as admin."); } if config.ldap_user_pass == SecUtf8::from("password") { println!("WARNING: Unsecure default admin password is used."); } if config.smtp_options.tls_required.is_some() { println!("DEPRECATED: smtp_options.tls_required field is deprecated, it never did anything. You can replace it with smtp_options.smtp_encryption."); } Ok(config) } #[cfg(test)] mod tests { use super::*; use clap::Parser; use figment::Jail; use pretty_assertions::assert_eq; #[test] fn check_generated_server_key() { assert_eq!( bincode::serialize( &get_server_setup("/doesnt/exist", "key seed", PrivateKeyLocation::Tests) .unwrap() .server_setup ) .unwrap(), [ 255, 206, 202, 50, 247, 13, 59, 191, 69, 244, 148, 187, 150, 227, 12, 250, 20, 207, 211, 151, 147, 33, 107, 132, 2, 252, 121, 94, 97, 6, 97, 232, 163, 168, 86, 246, 249, 186, 31, 204, 59, 75, 65, 134, 108, 159, 15, 70, 246, 250, 150, 195, 54, 197, 195, 176, 150, 200, 157, 119, 13, 173, 119, 8, 32, 0, 0, 0, 0, 0, 0, 0, 248, 123, 35, 91, 194, 51, 52, 57, 191, 210, 68, 227, 107, 166, 232, 37, 195, 244, 100, 84, 88, 212, 190, 12, 195, 57, 83, 72, 127, 189, 179, 16, 32, 0, 0, 0, 0, 0, 0, 0, 128, 112, 60, 207, 205, 69, 67, 73, 24, 175, 187, 62, 16, 45, 59, 136, 78, 40, 187, 54, 159, 94, 116, 33, 133, 119, 231, 43, 199, 164, 141, 7, 32, 0, 0, 0, 0, 0, 0, 0, 212, 134, 53, 203, 131, 24, 138, 211, 162, 28, 23, 233, 251, 82, 34, 66, 98, 12, 249, 205, 35, 208, 241, 50, 128, 131, 46, 189, 211, 51, 56, 109, 32, 0, 0, 0, 0, 0, 0, 0, 84, 20, 147, 25, 50, 5, 243, 203, 216, 180, 175, 121, 159, 96, 123, 183, 146, 251, 22, 44, 98, 168, 67, 224, 255, 139, 159, 25, 24, 254, 88, 3 ] ); } fn default_run_opts() -> RunOpts { RunOpts::parse_from::<_, std::ffi::OsString>([]) } fn write_random_key(jail: &Jail, file: &str) { use std::io::Write; let file = std::fs::File::create(jail.directory().join(file)).unwrap(); let mut writer = std::io::BufWriter::new(file); writer .write_all(&generate_random_private_key().serialize()) .unwrap(); } #[test] fn figment_location_extraction_key_file() { Jail::expect_with(|jail| { jail.create_file("lldap_config.toml", r#"key_file = "test""#)?; jail.set_env("LLDAP_KEY_SEED", "a123"); let ignore_keys = ["key_file", "cert_file"]; let figment_config = Figment::from(Serialized::defaults( ConfigurationBuilder::default().private_build().unwrap(), )) .merge(FileAdapter::wrap(Toml::file("lldap_config.toml")).ignore(&ignore_keys)) .merge(FileAdapter::wrap(Env::prefixed("LLDAP_").split("__")).ignore(&ignore_keys)); assert_eq!( PrivateKeyLocationOrFigment::Figment(figment_config).for_key_file("path"), PrivateKeyLocation::KeyFile( ConfigLocation::ConfigFile( jail.directory() .join("lldap_config.toml") .to_string_lossy() .to_string() ), "path".into() ) ); Ok(()) }); } #[test] fn check_server_setup_key_extraction_seed_success_with_nonexistant_file() { Jail::expect_with(|jail| { jail.create_file("lldap_config.toml", r#"key_file = "test""#)?; jail.set_env("LLDAP_KEY_SEED", "a123"); init(default_run_opts()).unwrap(); Ok(()) }); } #[test] fn check_server_setup_key_extraction_seed_failure_with_existing_file() { Jail::expect_with(|jail| { jail.create_file("lldap_config.toml", r#"key_file = "test""#)?; jail.set_env("LLDAP_KEY_SEED", "a123"); write_random_key(jail, "test"); init(default_run_opts()).unwrap_err(); Ok(()) }); } #[test] fn check_server_setup_key_extraction_file_success_with_existing_file() { Jail::expect_with(|jail| { jail.create_file("lldap_config.toml", r#"key_file = "test""#)?; write_random_key(jail, "test"); init(default_run_opts()).unwrap(); Ok(()) }); } #[test] fn check_server_setup_key_extraction_file_success_with_nonexistent_file() { Jail::expect_with(|jail| { jail.create_file("lldap_config.toml", r#"key_file = "test""#)?; init(default_run_opts()).unwrap(); Ok(()) }); } #[test] fn check_server_setup_key_extraction_file_with_previous_different_file() { Jail::expect_with(|jail| { jail.create_file("lldap_config.toml", r#"key_file = "test""#)?; write_random_key(jail, "test"); let config = init(default_run_opts()).unwrap(); let info = config.get_private_key_info(); write_random_key(jail, "test"); let new_config = init(default_run_opts()).unwrap(); let error_message = compare_private_key_hashes(Some(&info), &new_config.get_private_key_info()) .unwrap_err() .to_string(); if let PrivateKeyLocation::KeyFile(_, file) = info.private_key_location { assert!( error_message.contains( "The contents of the private key file from \"test\" have changed" ), "{error_message}" ); assert_eq!(file, "test"); } else { panic!( "Unexpected private key location: {:?}", info.private_key_location ); } Ok(()) }); } #[test] fn check_server_setup_key_extraction_file_to_seed() { Jail::expect_with(|jail| { jail.create_file("lldap_config.toml", "")?; write_random_key(jail, "server_key"); init(default_run_opts()).unwrap(); jail.create_file("lldap_config.toml", r#"key_seed = "test""#)?; let error_message = init(default_run_opts()).unwrap_err().to_string(); assert!( error_message.contains("A key_seed was given, but a key file already exists at",), "{error_message}" ); Ok(()) }); } #[test] fn check_server_setup_key_extraction_file_to_seed_removed_file() { Jail::expect_with(|jail| { jail.create_file("lldap_config.toml", "")?; write_random_key(jail, "server_key"); let config = init(default_run_opts()).unwrap(); let info = config.get_private_key_info(); std::fs::remove_file(jail.directory().join("server_key")).unwrap(); jail.create_file("lldap_config.toml", r#"key_seed = "test""#)?; let new_config = init(default_run_opts()).unwrap(); let error_message = compare_private_key_hashes(Some(&info), &new_config.get_private_key_info()) .unwrap_err() .to_string(); assert!( error_message.contains("but it used to come from default key file",), "{error_message}" ); Ok(()) }); } }