use std::{
collections::HashMap,
error::Error,
fmt::Display,
fs, io,
path::{Path, PathBuf},
};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use url::Url;
use icinga_client::client::{self, Client, Credentials, IcingaUrl};
pub const SERVER_CONFIG_NAME: &'static str = "istamon-servers.toml";
pub const PROJECT_NAME: &'static str = "istamon";
pub const QUALIFIER: &'static str = "de.lu-fennell";
pub trait PasswordManager {
fn load_password(&self, host: &str, user: &str) -> Result<Option<String>, Box<dyn Error>>;
fn store_password(&self, host: &str, user: &str, password: &str) -> Result<(), Box<dyn Error>>;
fn name(&self) -> String;
}
#[derive(Debug, Clone, PartialEq)]
pub struct Cfg {
url: IcingaUrl,
credentials: Option<Credentials>,
ca_cert: Option<PathBuf>,
password_source: PasswordSource,
}
impl Cfg {
pub fn new(
password_loader: Option<&dyn PasswordManager>,
configured_servers: &HashMap<String, IcingaApiServer>,
server_name_or_url: Option<&str>,
default_name: &str,
) -> Result<Cfg, String> {
fn credentials_from_url(
url: &IcingaUrl,
) -> (IcingaUrl, Option<Credentials>, PasswordSource) {
let (credentials, url) = url.split_credentials();
let password_source = credentials
.as_ref()
.and_then(|(_, p)| p.as_ref().map(|_| PasswordSource::Unencrypted))
.unwrap_or(PasswordSource::None);
(url, credentials, password_source)
}
let entry_name = if let Some(ref name) = server_name_or_url {
name.as_ref()
} else {
default_name
};
let selected_server = configured_servers.get(entry_name);
let mut cfg = match (selected_server, server_name_or_url) {
(Some(selected_server), _) => {
let (url, credentials, password_source) =
credentials_from_url(&selected_server.url);
Ok(Cfg {
url,
credentials,
ca_cert: selected_server.get_cert_path(),
password_source,
})
}
(None, Some(url_string)) => {
let maybe_url: Result<IcingaUrl, String> = url_string.parse();
match maybe_url {
Ok(url) => {
let (url, credentials, password_source) = credentials_from_url(&url);
Ok(Cfg {
url,
credentials,
ca_cert: None,
password_source,
})
}
Err(e) => Err(format!(
"No default server configured and url '{}' cannot be parsed: {}",
url_string, e
)),
}
}
(None, None) => Err("No url specified and no default server configured".to_string()),
}?;
if let Some((user, None)) = cfg.credentials {
if let Some(password_loader) = password_loader {
let password = password_loader
.load_password(&cfg.url.host_str(), &user)
.map_err(|e| e.to_string())?;
if password.is_some() {
cfg.password_source = PasswordSource::Encrypted {
provider: password_loader.name(),
}
}
cfg.credentials = Some((user, password));
} else {
cfg.credentials = Some((user, None))
}
}
Ok(cfg)
}
pub fn new_with_credentials(
url: IcingaUrl,
user: String,
password: String,
password_source: PasswordSource,
ca_cert: Option<PathBuf>,
) -> Self {
Self {
url,
credentials: Some((user, Some(password))),
ca_cert,
password_source,
}
}
pub fn new_without_credentials(url: IcingaUrl, ca_cert: Option<PathBuf>) -> Self {
Self {
url,
credentials: None,
ca_cert,
password_source: PasswordSource::None,
}
}
pub fn into_client(self) -> client::Result<Client> {
Client::new(self.url, self.ca_cert, self.credentials)
}
pub fn credentials(&self) -> Option<&Credentials> {
self.credentials.as_ref()
}
pub fn url(&self) -> &IcingaUrl {
&self.url
}
pub fn password_source(&self) -> &PasswordSource {
&self.password_source
}
pub fn ca_cert(&self) -> Option<&PathBuf> {
self.ca_cert.as_ref()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PasswordSource {
None,
Encrypted { provider: String },
Unencrypted,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct IcingaApiServer {
pub url: IcingaUrl,
pub ca_cert: Option<String>,
}
impl IcingaApiServer {
pub fn get_cert_path(&self) -> Option<PathBuf> {
self.ca_cert.clone().and_then(|p| {
default_config_dir().and_then(|config_dir| {
let path = config_dir.join(p);
if path.exists() {
Some(path)
} else {
None
}
})
})
}
pub fn get_user(&self) -> Option<String> {
get_user_from_url(self.url.get())
}
}
fn get_user_from_url(url: &Url) -> Option<String> {
let username = url.username();
if username.is_empty() {
None
} else {
Some(username.to_string())
}
}
impl From<&Cfg> for IcingaApiServer {
fn from(cfg: &Cfg) -> Self {
let mut url = cfg.url.clone();
if let Some((user, password)) = &cfg.credentials {
url.set_username(user);
if cfg.password_source == PasswordSource::Unencrypted {
url.set_password(password.as_ref().map(|s| s as &str));
}
}
Self {
url,
ca_cert: cfg
.ca_cert
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
}
}
}
#[derive(Debug)]
pub enum LoadError {
Toml(PathBuf, toml::de::Error),
Io(PathBuf, io::Error),
}
impl Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::Toml(p, e) => {
write!(f, "error deserializing {}: {}", p.to_string_lossy(), e)
}
LoadError::Io(p, e) => write!(f, "error reading {}: {}", p.to_string_lossy(), e),
}
}
}
impl Error for LoadError {}
pub fn save_api_server_configs(
config_file_path: &Path,
servers: &HashMap<String, IcingaApiServer>,
) -> Result<(), String> {
fs::write(
config_file_path,
toml::to_vec(&servers).map_err(|e| e.to_string())?,
)
.map_err(|e| format!("Error writing file: {}", e))?;
Ok(())
}
pub fn load_api_server_configs_or_empty<T: AsRef<Path>>(
config_file_path: Option<T>,
) -> Result<HashMap<String, IcingaApiServer>, LoadError> {
match config_file_path {
Some(p) if p.as_ref().exists() => load_api_server_configs(p.as_ref()),
_ => Ok(HashMap::new()),
}
}
pub fn load_api_server_configs(
config_file_path: &Path,
) -> Result<HashMap<String, IcingaApiServer>, LoadError> {
let content = fs::read_to_string(&config_file_path)
.map_err(|e| LoadError::Io(config_file_path.to_path_buf(), e))?;
toml::from_str(&content).map_err(|e| LoadError::Toml(config_file_path.to_path_buf(), e))
}
pub fn default_config_file() -> Option<PathBuf> {
default_config_dir().map(|p| p.join(SERVER_CONFIG_NAME))
}
fn default_config_dir() -> Option<PathBuf> {
ProjectDirs::from(QUALIFIER, "", PROJECT_NAME)
.map(|project_dirs| project_dirs.config_dir().to_path_buf())
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
fn no_servers() -> HashMap<String, IcingaApiServer> {
HashMap::new()
}
#[test]
fn test_cfg_only_cli_url_no_auth() {
let cfg_result = Cfg::new(
None,
&no_servers(),
Some("http://127.0.0.1:8080"),
"default",
);
assert_eq!(
cfg_result,
Ok(Cfg {
url: "http://127.0.0.1:8080".parse().unwrap(),
credentials: None,
password_source: PasswordSource::None,
ca_cert: None,
})
)
}
#[test]
fn test_cfg_only_cli_url_auth() {
let cfg_result = Cfg::new(
None,
&no_servers(),
Some("http://test_user:test_pass@127.0.0.1:8080"),
"default",
);
assert_eq!(
cfg_result,
Ok(Cfg {
url: "http://127.0.0.1:8080".parse().unwrap(),
credentials: Some(("test_user".to_string(), Some("test_pass".to_string()))),
password_source: PasswordSource::Unencrypted,
ca_cert: None
})
)
}
#[test]
fn test_cfg_only_cli_no_url() {
let cfg_result = Cfg::new(None, &no_servers(), None, "default");
assert_eq!(
cfg_result,
Err("No url specified and no default server configured".to_string())
)
}
fn default_server(url: &str) -> HashMap<String, IcingaApiServer> {
HashMap::from([(
"default".to_string(),
IcingaApiServer {
url: url.parse().unwrap(),
ca_cert: None,
},
)])
}
#[test]
fn test_cfg_default_configured_no_auth() {
let cfg_result = Cfg::new(
None,
&default_server("https://example.com"),
None,
"default",
);
assert_eq!(
cfg_result,
Ok(Cfg {
url: "https://example.com".parse().unwrap(),
credentials: None,
password_source: PasswordSource::None,
ca_cert: None
})
)
}
#[test]
fn test_cfg_default_configured_auth() {
let cfg_result = Cfg::new(
None,
&default_server("https://test:mutti123@example.com"),
Some("default"),
"blabla",
);
assert_eq!(
cfg_result,
Ok(Cfg {
url: "https://example.com".parse().unwrap(),
credentials: Some(("test".to_string(), Some("mutti123".to_string()))),
password_source: PasswordSource::Unencrypted,
ca_cert: None
})
)
}
#[test]
fn test_cfg_default_not_configured_no_url() {
let cfg_result = Cfg::new(None, &no_servers(), None, "default");
assert_eq!(
cfg_result,
Err("No url specified and no default server configured".to_string())
)
}
struct TestLoader(&'static str, &'static str);
impl PasswordManager for TestLoader {
fn load_password(&self, host: &str, user: &str) -> Result<Option<String>, Box<dyn Error>> {
if host == self.0 && user == self.1 {
Ok(Some("mutti123".to_string()))
} else {
Ok(None)
}
}
fn name(&self) -> String {
"test-loader".to_string()
}
fn store_password(
&self,
_host: &str,
_user: &str,
_password: &str,
) -> Result<(), Box<dyn Error>> {
unimplemented!()
}
}
#[test]
fn test_cfg_auth_password_loader() {
let cfg_result = Cfg::new(
Some(&TestLoader("example.com", "user")),
&no_servers(),
Some("https://user@example.com"),
"blabla",
);
assert_eq!(
cfg_result,
Ok(Cfg {
url: "https://example.com".parse().unwrap(),
credentials: Some(("user".to_string(), Some("mutti123".to_string()))),
password_source: PasswordSource::Encrypted {
provider: "test-loader".to_string()
},
ca_cert: None
})
)
}
#[test]
fn test_cfg_auth_password_loader_user_not_found() {
let cfg_result = Cfg::new(
Some(&TestLoader("example.com", "user")),
&no_servers(),
Some("https://user2@example.com"),
"blabla",
);
assert_eq!(
cfg_result,
Ok(Cfg {
url: "https://example.com".parse().unwrap(),
credentials: Some(("user2".to_string(), None)),
password_source: PasswordSource::None,
ca_cert: None
})
)
}
#[test]
fn test_cfg_auth_password_loader_no_password_for_host() {
let cfg_result = Cfg::new(
Some(&TestLoader("example.com", "user")),
&no_servers(),
Some("https://user@example2.com"),
"blabla",
);
assert_eq!(
cfg_result,
Ok(Cfg {
url: "https://example2.com".parse().unwrap(),
credentials: Some(("user".to_string(), None)),
password_source: PasswordSource::None,
ca_cert: None
})
)
}
#[test]
fn test_cfg2server_only_url() {
let cfg = Cfg {
url: "http://127.0.0.1:5665".parse().unwrap(),
credentials: None,
ca_cert: None,
password_source: PasswordSource::None,
};
let server: IcingaApiServer = IcingaApiServer::from(&cfg);
assert_eq!(
server,
IcingaApiServer {
url: "http://127.0.0.1:5665".parse().unwrap(),
ca_cert: None,
}
)
}
#[test]
fn test_cfg2server_url_with_username_no_password() {
let cfg = Cfg {
url: "http://127.0.0.1:5665".parse().unwrap(),
credentials: Some(("user".to_string(), None)),
ca_cert: None,
password_source: PasswordSource::Unencrypted,
};
let server: IcingaApiServer = IcingaApiServer::from(&cfg);
assert_eq!(
server,
IcingaApiServer {
url: "http://user@127.0.0.1:5665".parse().unwrap(),
ca_cert: None,
}
)
}
#[test]
fn test_cfg2server_url_with_username_and_password() {
let cfg = Cfg {
url: "http://127.0.0.1:5665".parse().unwrap(),
credentials: Some(("user".to_string(), Some("mutti123".to_string()))),
ca_cert: None,
password_source: PasswordSource::Unencrypted,
};
let server: IcingaApiServer = IcingaApiServer::from(&cfg);
assert_eq!(
server,
IcingaApiServer {
url: "http://user:mutti123@127.0.0.1:5665".parse().unwrap(),
ca_cert: None,
}
)
}
#[test]
fn test_cfg2server_url_with_username_and_password_encrypted() {
let cfg = Cfg {
url: "http://127.0.0.1:5665".parse().unwrap(),
credentials: Some(("user".to_string(), Some("mutti123".to_string()))),
ca_cert: None,
password_source: PasswordSource::Encrypted {
provider: "provider".to_string(),
},
};
let server: IcingaApiServer = IcingaApiServer::from(&cfg);
assert_eq!(
server,
IcingaApiServer {
url: "http://user@127.0.0.1:5665".parse().unwrap(),
ca_cert: None,
}
)
}
#[test]
fn test_cfg2server_url_with_username_and_password_dontsave() {
let cfg = Cfg {
url: "http://127.0.0.1:5665".parse().unwrap(),
credentials: Some(("user".to_string(), Some("mutti123".to_string()))),
ca_cert: None,
password_source: PasswordSource::None,
};
let server: IcingaApiServer = IcingaApiServer::from(&cfg);
assert_eq!(
server,
IcingaApiServer {
url: "http://user@127.0.0.1:5665".parse().unwrap(),
ca_cert: None,
}
)
}
#[test]
fn test_cfg2server_url_with_cacert() {
let cfg = Cfg {
url: "http://127.0.0.1:5665".parse().unwrap(),
credentials: Some(("user".to_string(), Some("mutti123".to_string()))),
ca_cert: Some(PathBuf::from("/path/to/cert.crt")),
password_source: PasswordSource::None,
};
let server: IcingaApiServer = IcingaApiServer::from(&cfg);
assert_eq!(
server,
IcingaApiServer {
url: "http://user@127.0.0.1:5665".parse().unwrap(),
ca_cert: Some("/path/to/cert.crt".to_string()),
}
)
}
}