diff --git a/src/system/config_client.rs b/src/system/config_client.rs new file mode 100644 index 0000000..28d2636 --- /dev/null +++ b/src/system/config_client.rs @@ -0,0 +1,453 @@ +//! ## ConfigClient +//! +//! `config_client` is the module which provides an API between the Config module and the system + +/* +* +* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com +* +* This file is part of "TermSCP" +* +* TermSCP is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* TermSCP is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with TermSCP. If not, see . +* +*/ + +// Deps +extern crate rand; +// Locals +use crate::config::serializer::ConfigSerializer; +use crate::config::{SerializerError, SerializerErrorKind, UserConfig}; +use crate::filetransfer::FileTransferProtocol; +// Ext +use std::fs::{create_dir, remove_file, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::string::ToString; + +// Types +pub type SshHost = (String, String, String); // 0: host, 1: username, 2: RSA key + +/// ## ConfigClient +/// +/// ConfigClient provides a high level API to communicate with the termscp configuration +pub struct ConfigClient { + config: UserConfig, // Configuration loaded + config_path: PathBuf, // Configuration TOML Path + ssh_key_dir: PathBuf, // SSH Key storage directory +} + +impl ConfigClient { + /// ### new + /// + /// Instantiate a new `ConfigClient` with provided path + pub fn new(config_path: &Path, ssh_key_dir: &Path) -> Result { + // Initialize a default configuration + let default_config: UserConfig = UserConfig::default(); + // Create client + let mut client: ConfigClient = ConfigClient { + config: default_config, + config_path: PathBuf::from(config_path), + ssh_key_dir: PathBuf::from(ssh_key_dir), + }; + // If ssh key directory doesn't exist, create it + if !ssh_key_dir.exists() { + if let Err(err) = create_dir(ssh_key_dir) { + return Err(SerializerError::new_ex( + SerializerErrorKind::IoError, + format!( + "Could not create SSH key directory \"{}\": {}", + ssh_key_dir.display(), + err + ), + )); + } + } + // If Config file doesn't exist, create it + if !config_path.exists() { + if let Err(err) = client.write_config() { + return Err(err); + } + } else { + // otherwise Load configuration from file + if let Err(err) = client.read_config() { + return Err(err); + } + } + Ok(client) + } + + // Text editor + + /// ### get_text_editor + /// + /// Get text editor from configuration + pub fn get_text_editor(&self) -> PathBuf { + self.config.user_interface.text_editor.clone() + } + + /// ### set_text_editor + /// + /// Set text editor path + pub fn set_text_editor(&mut self, path: PathBuf) { + self.config.user_interface.text_editor = path; + } + + // Default protocol + + /// ### get_default_protocol + /// + /// Get default protocol from configuration + pub fn get_default_protcol(&self) -> FileTransferProtocol { + match FileTransferProtocol::from_str(self.config.user_interface.default_protocol.as_str()) { + Ok(p) => p, + Err(_) => FileTransferProtocol::Sftp, + } + } + + /// ### set_default_protocol + /// + /// Set default protocol to configuration + pub fn set_default_protocol(&mut self, proto: FileTransferProtocol) { + self.config.user_interface.default_protocol = proto.to_string(); + } + + // SSH Keys + + /// ### save_ssh_key + /// + /// Save a SSH key into configuration. + /// This operation also creates the key file in `ssh_key_dir` + /// and also commits changes to configuration, to prevent incoerent data + pub fn add_ssh_key( + &mut self, + host: &str, + username: &str, + ssh_key: &str, + ) -> Result<(), SerializerError> { + let host_name: String = Self::make_ssh_host_key(host, username); + // Get key path + let ssh_key_path: PathBuf = { + let mut p: PathBuf = self.ssh_key_dir.clone(); + p.push(format!("{}.key", host_name)); + p + }; + // Write key to file + let mut f: File = match File::create(ssh_key_path.as_path()) { + Ok(f) => f, + Err(err) => return Self::make_io_err(err), + }; + if let Err(err) = f.write_all(ssh_key.as_bytes()) { + return Self::make_io_err(err); + } + // Add host to keys + self.config.remote.ssh_keys.insert(host_name, ssh_key_path); + // Write config + self.write_config() + } + + /// ### del_ssh_key + /// + /// Delete a ssh key from configuration, using host as key. + /// This operation also unlinks the key file in `ssh_key_dir` + /// and also commits changes to configuration, to prevent incoerent data + pub fn del_ssh_key(&mut self, host: &str, username: &str) -> Result<(), SerializerError> { + // Remove key from configuration and get key path + let key_path: PathBuf = match self + .config + .remote + .ssh_keys + .remove(&Self::make_ssh_host_key(host, username)) + { + Some(p) => p, + None => return Ok(()), // Return ok if host doesn't exist + }; + // Remove file + if let Err(err) = remove_file(key_path.as_path()) { + return Self::make_io_err(err); + } + // Commit changes to configuration + self.write_config() + } + + /// ### get_ssh_key + /// + /// Get ssh key from host. + /// None is returned if key doesn't exist + /// `std::io::Error` is returned in case it was not possible to read the key file + pub fn get_ssh_key(&self, mkey: &str) -> std::io::Result> { + // Check if Key exists + match self.config.remote.ssh_keys.get(mkey) { + None => Ok(None), + Some(key_path) => { + // Read key path + let mut key_file: File = File::open(key_path)?; + // Read + let mut key: String = String::new(); + key_file.read_to_string(&mut key)?; + // Get host and username + let (host, username): (String, String) = Self::get_ssh_tokens(mkey); + // Return key + Ok(Some((host, username, key))) + } + } + } + + /// ### iter_ssh_keys + /// + /// Get an iterator through hosts in the ssh key storage + pub fn iter_ssh_keys(&self) -> Box + '_> { + Box::new(self.config.remote.ssh_keys.keys()) + } + + // I/O + + /// ### write_config + /// + /// Write configuration to file + pub fn write_config(&self) -> Result<(), SerializerError> { + // Open file + match OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(self.config_path.as_path()) + { + Ok(writer) => { + let serializer: ConfigSerializer = ConfigSerializer {}; + serializer.serialize(Box::new(writer), &self.config) + } + Err(err) => Err(SerializerError::new_ex( + SerializerErrorKind::IoError, + err.to_string(), + )), + } + } + + /// ### read_config + /// + /// Read configuration from file (or reload it if already read) + pub fn read_config(&mut self) -> Result<(), SerializerError> { + // Open bookmarks file for read + match OpenOptions::new() + .read(true) + .open(self.config_path.as_path()) + { + Ok(reader) => { + // Deserialize + let deserializer: ConfigSerializer = ConfigSerializer {}; + match deserializer.deserialize(Box::new(reader)) { + Ok(config) => { + self.config = config; + Ok(()) + } + Err(err) => Err(err), + } + } + Err(err) => Err(SerializerError::new_ex( + SerializerErrorKind::IoError, + err.to_string(), + )), + } + } + + /// ### make_ssh_host_key + /// + /// Hosts are saved as `username@host` into configuration. + /// This method creates the key name, starting from host and username + fn make_ssh_host_key(host: &str, username: &str) -> String { + format!("{}@{}", username, host) + } + + /// ### get_ssh_tokens + /// + /// Get ssh tokens starting from ssh host key + /// Panics if key has invalid syntax + /// Returns: (host, username) + fn get_ssh_tokens(host_key: &str) -> (String, String) { + let tokens: Vec<&str> = host_key.split('@').collect(); + assert_eq!(tokens.len(), 2); + (String::from(tokens[1]), String::from(tokens[0])) + } + + /// ### make_io_err + /// + /// Make serializer error from `std::io::Error` + fn make_io_err(err: std::io::Error) -> Result<(), SerializerError> { + Err(SerializerError::new_ex( + SerializerErrorKind::IoError, + err.to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::config::UserConfig; + use crate::utils::random::random_alphanumeric_with_len; + + #[test] + fn test_system_config_new() { + let tmp_dir: tempfile::TempDir = create_tmp_dir(); + let (cfg_path, ssh_keys_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let client: ConfigClient = ConfigClient::new(cfg_path.as_path(), ssh_keys_path.as_path()) + .ok() + .unwrap(); + // Verify parameters + let default_config: UserConfig = UserConfig::default(); + assert_eq!(client.config.remote.ssh_keys.len(), 0); + assert_eq!( + client.config.user_interface.default_protocol, + default_config.user_interface.default_protocol + ); + assert_eq!( + client.config.user_interface.text_editor, + default_config.user_interface.text_editor + ); + assert_eq!(client.config_path, cfg_path); + assert_eq!(client.ssh_key_dir, ssh_keys_path); + } + + #[test] + fn test_system_config_new_err() { + assert!( + ConfigClient::new(Path::new("/tmp/oifoif/omar"), Path::new("/tmp/efnnu/omar"),) + .is_err() + ); + let tmp_dir: tempfile::TempDir = create_tmp_dir(); + let (cfg_path, _): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + assert!(ConfigClient::new(cfg_path.as_path(), Path::new("/tmp/efnnu/omar")).is_err()); + } + + #[test] + fn test_system_config_from_existing() { + let tmp_dir: tempfile::TempDir = create_tmp_dir(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + // Change some stuff + client.set_text_editor(PathBuf::from("/usr/bin/vim")); + client.set_default_protocol(FileTransferProtocol::Scp); + assert!(client + .add_ssh_key("192.168.1.31", "pi", "piroporopero") + .is_ok()); + assert!(client.write_config().is_ok()); + // Istantiate a new client + let client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + // Verify client has updated parameters + assert_eq!(client.get_default_protcol(), FileTransferProtocol::Scp); + assert_eq!(client.get_text_editor(), PathBuf::from("/usr/bin/vim")); + assert_eq!( + client.get_ssh_key("pi@192.168.1.31").unwrap().unwrap(), + ( + String::from("192.168.1.31"), + String::from("pi"), + String::from("piroporopero") + ) + ); + } + + #[test] + fn test_system_config_text_editor() { + let tmp_dir: tempfile::TempDir = create_tmp_dir(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + client.set_text_editor(PathBuf::from("mcedit")); + assert_eq!(client.get_text_editor(), PathBuf::from("mcedit")); + } + + #[test] + fn test_system_config_default_protocol() { + let tmp_dir: tempfile::TempDir = create_tmp_dir(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + client.set_default_protocol(FileTransferProtocol::Ftp(true)); + assert_eq!( + client.get_default_protcol(), + FileTransferProtocol::Ftp(true) + ); + } + + #[test] + fn test_system_config_ssh_keys() { + let tmp_dir: tempfile::TempDir = create_tmp_dir(); + let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path()); + let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path()) + .ok() + .unwrap(); + // Add a new key + let rsa_key: String = get_sample_rsa_key(); + assert!(client + .add_ssh_key("192.168.1.31", "pi", rsa_key.as_str()) + .is_ok()); + // Iterate keys + for key in client.iter_ssh_keys() { + let host: SshHost = client.get_ssh_key(key).ok().unwrap().unwrap(); + println!("{:?}", host); + assert_eq!(host.0, String::from("192.168.1.31")); + assert_eq!(host.1, String::from("pi")); + assert_eq!(host.2, rsa_key); + } + // Unexisting key + assert!(client.get_ssh_key("test").ok().unwrap().is_none()); + // Delete key + assert!(client.del_ssh_key("192.168.1.31", "pi").is_ok()); + } + + #[test] + fn test_system_config_make_key() { + assert_eq!( + ConfigClient::make_ssh_host_key("192.168.1.31", "pi"), + String::from("pi@192.168.1.31") + ); + assert_eq!( + ConfigClient::get_ssh_tokens("pi@192.168.1.31"), + (String::from("192.168.1.31"), String::from("pi")) + ); + } + + /// ### get_paths + /// + /// Get paths for configuration and keys directory + fn get_paths(dir: &Path) -> (PathBuf, PathBuf) { + let mut k: PathBuf = PathBuf::from(dir); + let mut c: PathBuf = k.clone(); + k.push("ssh-keys/"); + c.push("config.toml"); + (c, k) + } + + /// ### create_tmp_dir + /// + /// Create temporary directory + fn create_tmp_dir() -> tempfile::TempDir { + tempfile::TempDir::new().ok().unwrap() + } + + fn get_sample_rsa_key() -> String { + format!( + "-----BEGIN OPENSSH PRIVATE KEY-----\n{}\n-----END OPENSSH PRIVATE KEY-----", + random_alphanumeric_with_len(2536) + ) + } +} diff --git a/src/system/mod.rs b/src/system/mod.rs index d0fe5be..f5a69a7 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -25,4 +25,5 @@ // modules pub mod bookmarks_client; +pub mod config_client; pub mod environment;