feat(opnsense-config): Public API now complete for dhcp add_static_mapping and remove_static_mapping, not perfect but good enough to move forward
This commit is contained in:
		
							parent
							
								
									b14d0ab686
								
							
						
					
					
						commit
						d30e909b83
					
				
							
								
								
									
										151
									
								
								harmony-rs/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										151
									
								
								harmony-rs/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @ -58,6 +58,19 @@ dependencies = [ | ||||
|  "subtle", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "ahash" | ||||
| version = "0.8.11" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" | ||||
| dependencies = [ | ||||
|  "cfg-if", | ||||
|  "const-random", | ||||
|  "once_cell", | ||||
|  "version_check", | ||||
|  "zerocopy", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "aho-corasick" | ||||
| version = "1.1.3" | ||||
| @ -203,6 +216,9 @@ name = "bitflags" | ||||
| version = "2.6.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" | ||||
| dependencies = [ | ||||
|  "serde", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "bitvec" | ||||
| @ -339,6 +355,26 @@ version = "0.9.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "const-random" | ||||
| version = "0.1.18" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" | ||||
| dependencies = [ | ||||
|  "const-random-macro", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "const-random-macro" | ||||
| version = "0.1.16" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" | ||||
| dependencies = [ | ||||
|  "getrandom", | ||||
|  "once_cell", | ||||
|  "tiny-keccak", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "core-foundation" | ||||
| version = "0.9.4" | ||||
| @ -373,6 +409,12 @@ dependencies = [ | ||||
|  "cfg-if", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "crunchy" | ||||
| version = "0.2.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "crypto-bigint" | ||||
| version = "0.5.5" | ||||
| @ -627,6 +669,18 @@ dependencies = [ | ||||
|  "miniz_oxide 0.8.0", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "flurry" | ||||
| version = "0.5.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" | ||||
| dependencies = [ | ||||
|  "ahash", | ||||
|  "num_cpus", | ||||
|  "parking_lot", | ||||
|  "seize", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "fnv" | ||||
| version = "1.0.7" | ||||
| @ -1099,6 +1153,16 @@ version = "0.4.14" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "lock_api" | ||||
| version = "0.4.12" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" | ||||
| dependencies = [ | ||||
|  "autocfg", | ||||
|  "scopeguard", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "log" | ||||
| version = "0.4.22" | ||||
| @ -1228,6 +1292,16 @@ dependencies = [ | ||||
|  "libm", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "num_cpus" | ||||
| version = "1.16.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" | ||||
| dependencies = [ | ||||
|  "hermit-abi", | ||||
|  "libc", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "object" | ||||
| version = "0.36.4" | ||||
| @ -1305,7 +1379,9 @@ dependencies = [ | ||||
|  "pretty_assertions", | ||||
|  "russh", | ||||
|  "russh-keys", | ||||
|  "russh-sftp", | ||||
|  "serde", | ||||
|  "serde_json", | ||||
|  "thiserror", | ||||
|  "tokio", | ||||
| ] | ||||
| @ -1364,6 +1440,29 @@ dependencies = [ | ||||
|  "sha2", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "parking_lot" | ||||
| version = "0.12.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" | ||||
| dependencies = [ | ||||
|  "lock_api", | ||||
|  "parking_lot_core", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "parking_lot_core" | ||||
| version = "0.9.10" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" | ||||
| dependencies = [ | ||||
|  "cfg-if", | ||||
|  "libc", | ||||
|  "redox_syscall", | ||||
|  "smallvec", | ||||
|  "windows-targets 0.52.6", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "password-hash" | ||||
| version = "0.4.2" | ||||
| @ -1582,6 +1681,15 @@ dependencies = [ | ||||
|  "getrandom", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "redox_syscall" | ||||
| version = "0.5.7" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" | ||||
| dependencies = [ | ||||
|  "bitflags 2.6.0", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "regex" | ||||
| version = "1.10.6" | ||||
| @ -1786,6 +1894,24 @@ dependencies = [ | ||||
|  "zeroize", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "russh-sftp" | ||||
| version = "2.0.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c2a72c8afe2041c17435eecd85d0b7291841486fd3d1c4082e0b212e5437ca42" | ||||
| dependencies = [ | ||||
|  "async-trait", | ||||
|  "bitflags 2.6.0", | ||||
|  "bytes", | ||||
|  "chrono", | ||||
|  "flurry", | ||||
|  "log", | ||||
|  "serde", | ||||
|  "thiserror", | ||||
|  "tokio", | ||||
|  "tokio-util", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "rust-ipmi" | ||||
| version = "0.1.1" | ||||
| @ -1862,6 +1988,12 @@ dependencies = [ | ||||
|  "windows-sys 0.52.0", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "scopeguard" | ||||
| version = "1.2.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "scrypt" | ||||
| version = "0.11.0" | ||||
| @ -1910,6 +2042,12 @@ dependencies = [ | ||||
|  "libc", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "seize" | ||||
| version = "0.3.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "semver" | ||||
| version = "1.0.23" | ||||
| @ -1938,9 +2076,9 @@ dependencies = [ | ||||
| 
 | ||||
| [[package]] | ||||
| name = "serde_json" | ||||
| version = "1.0.128" | ||||
| version = "1.0.133" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" | ||||
| checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" | ||||
| dependencies = [ | ||||
|  "itoa", | ||||
|  "memchr", | ||||
| @ -2193,6 +2331,15 @@ dependencies = [ | ||||
|  "syn 2.0.77", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "tiny-keccak" | ||||
| version = "2.0.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" | ||||
| dependencies = [ | ||||
|  "crunchy", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "tinyvec" | ||||
| version = "1.8.0" | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| mod xml_utils; | ||||
| mod data; | ||||
| pub use data::*; | ||||
| pub use yaserde::MaybeString; | ||||
|  | ||||
| @ -16,6 +16,8 @@ async-trait = { workspace = true } | ||||
| tokio = { workspace = true } | ||||
| opnsense-config-xml = { path = "../opnsense-config-xml" } | ||||
| chrono = "0.4.38" | ||||
| russh-sftp = "2.0.6" | ||||
| serde_json = "1.0.133" | ||||
| 
 | ||||
| [dev-dependencies] | ||||
| pretty_assertions = "1.4.1" | ||||
|  | ||||
| @ -1,17 +1,23 @@ | ||||
| use std::sync::Arc; | ||||
| 
 | ||||
| use crate::{error::Error, modules::dhcp::DhcpConfig}; | ||||
| use log::trace; | ||||
| use opnsense_config_xml::OPNsense; | ||||
| 
 | ||||
| use super::ConfigManager; | ||||
| use super::{ConfigManager, OPNsenseShell}; | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| pub struct Config { | ||||
|     opnsense: OPNsense, | ||||
|     repository: Box<dyn ConfigManager + Send + Sync>, | ||||
|     repository: Arc<dyn ConfigManager>, | ||||
|     shell: Arc<dyn OPNsenseShell>, | ||||
| } | ||||
| 
 | ||||
| impl Config { | ||||
|     pub async fn new(repository: Box<dyn ConfigManager + Send + Sync>) -> Result<Self, Error> { | ||||
|     pub async fn new( | ||||
|         repository: Arc<dyn ConfigManager>, | ||||
|         shell: Arc<dyn OPNsenseShell>, | ||||
|     ) -> Result<Self, Error> { | ||||
|         let xml = repository.load_as_str().await?; | ||||
|         trace!("xml {}", xml); | ||||
| 
 | ||||
| @ -20,21 +26,24 @@ impl Config { | ||||
|         Ok(Self { | ||||
|             opnsense, | ||||
|             repository, | ||||
|             shell, | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     pub fn dhcp(&mut self) -> DhcpConfig { | ||||
|         DhcpConfig::new(&mut self.opnsense) | ||||
|         DhcpConfig::new(&mut self.opnsense, self.shell.clone()) | ||||
|     } | ||||
| 
 | ||||
|     pub async fn apply(&self) -> Result<(), Error> { | ||||
|         self.repository.apply_new_config(&self.opnsense.to_xml()).await | ||||
|         self.repository | ||||
|             .apply_new_config(&self.opnsense.to_xml()) | ||||
|             .await | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use crate::config::LocalFileConfigManager; | ||||
|     use crate::config::{DummyOPNSenseShell, LocalFileConfigManager}; | ||||
|     use crate::modules::dhcp::DhcpConfig; | ||||
|     use std::fs; | ||||
|     use std::net::Ipv4Addr; | ||||
| @ -55,9 +64,10 @@ mod tests { | ||||
| 
 | ||||
|             let config_file_path = test_file_path.to_str().unwrap().to_string(); | ||||
|             println!("File path {config_file_path}"); | ||||
|             let repository = Box::new(LocalFileConfigManager::new(config_file_path)); | ||||
|             let repository = Arc::new(LocalFileConfigManager::new(config_file_path)); | ||||
|             let shell = Arc::new(DummyOPNSenseShell {}); | ||||
|             let config_file_str = repository.load_as_str().await.unwrap(); | ||||
|             let config = Config::new(repository) | ||||
|             let config = Config::new(repository, shell) | ||||
|                 .await | ||||
|                 .expect("Failed to load config"); | ||||
| 
 | ||||
| @ -82,14 +92,15 @@ mod tests { | ||||
| 
 | ||||
|         let config_file_path = test_file_path.to_str().unwrap().to_string(); | ||||
|         println!("File path {config_file_path}"); | ||||
|         let repository = Box::new(LocalFileConfigManager::new(config_file_path)); | ||||
|         let mut config = Config::new(repository) | ||||
|         let repository = Arc::new(LocalFileConfigManager::new(config_file_path)); | ||||
|         let shell = Arc::new(DummyOPNSenseShell {}); | ||||
|         let mut config = Config::new(repository, shell.clone()) | ||||
|             .await | ||||
|             .expect("Failed to load config"); | ||||
| 
 | ||||
|         println!("Config {:?}", config); | ||||
| 
 | ||||
|         let mut dhcp_config = DhcpConfig::new(&mut config.opnsense); | ||||
|         let mut dhcp_config = DhcpConfig::new(&mut config.opnsense, shell); | ||||
|         dhcp_config | ||||
|             .add_static_mapping( | ||||
|                 "00:00:00:00:00:00", | ||||
|  | ||||
| @ -1,33 +1,9 @@ | ||||
| use crate::config::manager::ConfigManager; | ||||
| use crate::config::{manager::ConfigManager, OPNsenseShell}; | ||||
| use crate::error::Error; | ||||
| use async_trait::async_trait; | ||||
| use log::{debug, info}; | ||||
| use russh::{ | ||||
|     client::{Config as SshConfig, Handler, Msg}, | ||||
|     Channel, | ||||
| }; | ||||
| use russh_keys::key::{self, KeyPair}; | ||||
| use russh_sftp::client::SftpSession; | ||||
| use std::{ | ||||
|     net::Ipv4Addr, | ||||
|     sync::Arc, | ||||
|     time::{SystemTime, UNIX_EPOCH}, | ||||
| }; | ||||
| use tokio::io::AsyncWriteExt; | ||||
| 
 | ||||
| struct Client {} | ||||
| 
 | ||||
| #[async_trait] | ||||
| impl Handler for Client { | ||||
|     type Error = Error; | ||||
| 
 | ||||
|     async fn check_server_key( | ||||
|         &mut self, | ||||
|         _server_public_key: &key::PublicKey, | ||||
|     ) -> Result<bool, Self::Error> { | ||||
|         Ok(true) | ||||
|     } | ||||
| } | ||||
| use log::info; | ||||
| use russh_keys::key::KeyPair; | ||||
| use std::sync::Arc; | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| pub enum SshCredentials { | ||||
| @ -37,170 +13,50 @@ pub enum SshCredentials { | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| pub struct SshConfigManager { | ||||
|     ssh_config: Arc<SshConfig>, | ||||
|     credentials: SshCredentials, | ||||
|     host: (Ipv4Addr, u16), | ||||
|     opnsense_shell: Arc<dyn OPNsenseShell>, | ||||
| } | ||||
| 
 | ||||
| impl SshConfigManager { | ||||
|     pub fn new( | ||||
|         host: (Ipv4Addr, u16), | ||||
|         credentials: SshCredentials, | ||||
|         ssh_config: Arc<SshConfig>, | ||||
|     ) -> Self { | ||||
|         Self { | ||||
|             ssh_config, | ||||
|             credentials, | ||||
|             host, | ||||
|         } | ||||
|     pub fn new(opnsense_shell: Arc<dyn OPNsenseShell>) -> Self { | ||||
|         Self { opnsense_shell } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl SshConfigManager { | ||||
|     async fn get_ssh_channel(&self) -> Result<Channel<Msg>, Error> { | ||||
|         let mut ssh = russh::client::connect(self.ssh_config.clone(), self.host, Client {}).await?; | ||||
| 
 | ||||
|         match &self.credentials { | ||||
|             SshCredentials::SshKey { username, key } => { | ||||
|                 ssh.authenticate_publickey(username, key.clone()).await?; | ||||
|             } | ||||
|             SshCredentials::Password { username, password } => { | ||||
|                 ssh.authenticate_password(username, password).await?; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         Ok(ssh.channel_open_session().await?) | ||||
|     } | ||||
| 
 | ||||
|     async fn write_content_to_temp_file(&self, content: &str) -> Result<String, Error> { | ||||
|         let temp_filename = format!( | ||||
|             "/tmp/opnsense-config-tmp-config_{}", | ||||
|             SystemTime::now() | ||||
|                 .duration_since(UNIX_EPOCH) | ||||
|                 .unwrap() | ||||
|                 .as_millis() | ||||
|         ); | ||||
|         let channel = self.get_ssh_channel().await?; | ||||
|         channel | ||||
|             .request_subsystem(true, "sftp") | ||||
|             .await | ||||
|             .expect("Should request sftp subsystem"); | ||||
|         let sftp = SftpSession::new(channel.into_stream()) | ||||
|             .await | ||||
|             .expect("Should acquire sftp subsystem"); | ||||
| 
 | ||||
|         let mut file = sftp.create(&temp_filename).await.unwrap(); | ||||
|         file.write_all(content.as_bytes()).await?; | ||||
| 
 | ||||
|         Ok(temp_filename) | ||||
|     } | ||||
|     async fn backup_config_remote(&self) -> Result<String, Error> { | ||||
|         let backup_filename = format!("config_{}.xml", chrono::Local::now().format("%Y%m%d%H%M%S")); | ||||
| 
 | ||||
|         self.run_command(&format!("cp /conf/config.xml /tmp/{}", backup_filename)) | ||||
|         self.opnsense_shell.exec(&format!("cp /conf/config.xml /tmp/{}", backup_filename)) | ||||
|             .await | ||||
|     } | ||||
| 
 | ||||
|     async fn move_to_live_config(&self, new_config_path: &str) -> Result<String, Error> { | ||||
|         info!("Overwriting OPNSense /conf/config.xml with {new_config_path}"); | ||||
|         self.run_command(&format!("mv {new_config_path} /conf/config.xml")) | ||||
|         self.opnsense_shell.exec(&format!("mv {new_config_path} /conf/config.xml")) | ||||
|             .await | ||||
|     } | ||||
| 
 | ||||
|     async fn reload_all_services(&self) -> Result<String, Error> { | ||||
|         info!("Reloading all opnsense services"); | ||||
|         self.run_command(&format!("configctl service reload all")) | ||||
|         self.opnsense_shell.exec(&format!("configctl service reload all")) | ||||
|             .await | ||||
|     } | ||||
| 
 | ||||
|     async fn run_command(&self, command: &str) -> Result<String, Error> { | ||||
|         debug!("Running ssh command {command}"); | ||||
|         let mut channel = self.get_ssh_channel().await?; | ||||
|         channel.exec(true, command).await?; | ||||
|         wait_for_completion(&mut channel).await | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[async_trait] | ||||
| impl ConfigManager for SshConfigManager { | ||||
|     async fn load_as_str(&self) -> Result<String, Error> { | ||||
|         let mut channel = self.get_ssh_channel().await?; | ||||
| 
 | ||||
|         channel.exec(true, "cat /conf/config.xml").await?; | ||||
|         let mut output: Vec<u8> = vec![]; | ||||
|         loop { | ||||
|             let Some(msg) = channel.wait().await else { | ||||
|                 break; | ||||
|             }; | ||||
| 
 | ||||
|             info!("got msg {:?}", msg); | ||||
|             match msg { | ||||
|                 russh::ChannelMsg::Data { ref data } => { | ||||
|                     output.append(&mut data.to_vec()); | ||||
|                 } | ||||
|                 russh::ChannelMsg::ExitStatus { .. } => {} | ||||
|                 russh::ChannelMsg::WindowAdjusted { .. } => {} | ||||
|                 russh::ChannelMsg::Success { .. } => {} | ||||
|                 russh::ChannelMsg::Eof { .. } => {} | ||||
|                 _ => todo!(), | ||||
|             } | ||||
|         } | ||||
|         Ok(String::from_utf8(output).expect("Valid utf-8 bytes")) | ||||
|         self.opnsense_shell.exec("cat /conf/config.xml").await | ||||
|     } | ||||
| 
 | ||||
|     async fn apply_new_config(&self, content: &str) -> Result<(), Error> { | ||||
|         let temp_filename = self.write_content_to_temp_file(content).await?; | ||||
|         let temp_filename = self | ||||
|             .opnsense_shell | ||||
|             .write_content_to_temp_file(content) | ||||
|             .await?; | ||||
|         self.backup_config_remote().await?; | ||||
|         self.move_to_live_config(&temp_filename).await?; | ||||
|         self.reload_all_services().await?; | ||||
| 
 | ||||
|         // TODO
 | ||||
|         // 1. use russh to copy the current opnsense /conf/config.xml to the backup folder with a
 | ||||
|         // proper name
 | ||||
|         //
 | ||||
|         // 2. use russh scp functionality to copy the content directly into a file
 | ||||
|         // if necessary, save it first to a local file with minimal permissions in
 | ||||
|         // /tmp/{randomname}
 | ||||
|         //
 | ||||
|         // 3. Use opnsense cli to validate the config file
 | ||||
|         //
 | ||||
|         // 4. Reload all opnsense services using opnsense cli (still through russh commands)
 | ||||
|         //
 | ||||
|         todo!("apply_new_config not fully implemented yet") | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| async fn wait_for_completion(channel: &mut Channel<Msg>) -> Result<String, Error> { | ||||
|     let mut output = Vec::new(); | ||||
| 
 | ||||
|     loop { | ||||
|         let Some(msg) = channel.wait().await else { | ||||
|             break; | ||||
|         }; | ||||
| 
 | ||||
|         match msg { | ||||
|             russh::ChannelMsg::ExtendedData { ref data, .. } | ||||
|             | russh::ChannelMsg::Data { ref data } => { | ||||
|                 output.append(&mut data.to_vec()); | ||||
|             } | ||||
|             russh::ChannelMsg::ExitStatus { exit_status } => { | ||||
|                 if exit_status != 0 { | ||||
|                     return Err(Error::Command(format!( | ||||
|                         "Command failed with exit status {exit_status}, output {}", | ||||
|                         String::from_utf8(output).unwrap_or_default() | ||||
|                     ))); | ||||
|                 } | ||||
|             } | ||||
|             russh::ChannelMsg::Success { .. } | ||||
|             | russh::ChannelMsg::WindowAdjusted { .. } | ||||
|             | russh::ChannelMsg::Eof { .. } => {} | ||||
|             _ => { | ||||
|                 return Err(Error::Unexpected(format!( | ||||
|                     "Russh got unexpected msg {msg:?}" | ||||
|                 ))) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     Ok(String::from_utf8(output).unwrap_or_default()) | ||||
| } | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| mod config; | ||||
| mod manager; | ||||
| mod shell; | ||||
| pub use manager::*; | ||||
| pub use config::*; | ||||
| pub use shell::*; | ||||
|  | ||||
							
								
								
									
										27
									
								
								harmony-rs/opnsense-config/src/config/shell/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								harmony-rs/opnsense-config/src/config/shell/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| mod ssh; | ||||
| pub use ssh::*; | ||||
| 
 | ||||
| use async_trait::async_trait; | ||||
| 
 | ||||
| use crate::Error; | ||||
| 
 | ||||
| #[async_trait] | ||||
| pub trait OPNsenseShell: std::fmt::Debug + Send + Sync { | ||||
|     async fn exec(&self, command: &str) -> Result<String, Error>; | ||||
|     async fn write_content_to_temp_file(&self, content: &str) -> Result<String, Error>; | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| #[derive(Debug)] | ||||
| pub struct DummyOPNSenseShell; | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| #[async_trait] | ||||
| impl OPNsenseShell for DummyOPNSenseShell { | ||||
|     async fn exec(&self, _command: &str) -> Result<String, Error> { | ||||
|         unimplemented!("This is a dummy implementation"); | ||||
|     } | ||||
|     async fn write_content_to_temp_file(&self, _content: &str) -> Result<String, Error> { | ||||
|         unimplemented!("This is a dummy implementation"); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										141
									
								
								harmony-rs/opnsense-config/src/config/shell/ssh.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								harmony-rs/opnsense-config/src/config/shell/ssh.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,141 @@ | ||||
| use std::{ | ||||
|     net::Ipv4Addr, | ||||
|     sync::Arc, | ||||
|     time::{SystemTime, UNIX_EPOCH}, | ||||
| }; | ||||
| 
 | ||||
| use async_trait::async_trait; | ||||
| use log::debug; | ||||
| use russh::{ | ||||
|     client::{Config, Handler, Msg}, | ||||
|     Channel, | ||||
| }; | ||||
| use russh_keys::key; | ||||
| use russh_sftp::client::SftpSession; | ||||
| use tokio::io::AsyncWriteExt; | ||||
| 
 | ||||
| use crate::{config::SshCredentials, Error}; | ||||
| 
 | ||||
| use super::OPNsenseShell; | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| pub struct SshOPNSenseShell { | ||||
|     host: (Ipv4Addr, u16), | ||||
|     credentials: SshCredentials, | ||||
|     ssh_config: Arc<Config>, | ||||
| } | ||||
| 
 | ||||
| #[async_trait] | ||||
| impl OPNsenseShell for SshOPNSenseShell { | ||||
|     async fn exec(&self, command: &str) -> Result<String, Error> { | ||||
|         self.run_command(command).await | ||||
|     } | ||||
| 
 | ||||
|     async fn write_content_to_temp_file(&self, content: &str) -> Result<String, Error> { | ||||
|         let temp_filename = format!( | ||||
|             "/tmp/opnsense-config-tmp-config_{}", | ||||
|             SystemTime::now() | ||||
|                 .duration_since(UNIX_EPOCH) | ||||
|                 .unwrap() | ||||
|                 .as_millis() | ||||
|         ); | ||||
|         let channel = self.get_ssh_channel().await?; | ||||
|         channel | ||||
|             .request_subsystem(true, "sftp") | ||||
|             .await | ||||
|             .expect("Should request sftp subsystem"); | ||||
|         let sftp = SftpSession::new(channel.into_stream()) | ||||
|             .await | ||||
|             .expect("Should acquire sftp subsystem"); | ||||
| 
 | ||||
|         let mut file = sftp.create(&temp_filename).await.unwrap(); | ||||
|         file.write_all(content.as_bytes()).await?; | ||||
| 
 | ||||
|         Ok(temp_filename) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl SshOPNSenseShell { | ||||
|     pub async fn get_ssh_channel(&self) -> Result<Channel<Msg>, Error> { | ||||
|         let mut ssh = russh::client::connect(self.ssh_config.clone(), self.host, Client {}).await?; | ||||
| 
 | ||||
|         match &self.credentials { | ||||
|             SshCredentials::SshKey { username, key } => { | ||||
|                 ssh.authenticate_publickey(username, key.clone()).await?; | ||||
|             } | ||||
|             SshCredentials::Password { username, password } => { | ||||
|                 ssh.authenticate_password(username, password).await?; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         Ok(ssh.channel_open_session().await?) | ||||
|     } | ||||
| 
 | ||||
|     async fn run_command(&self, command: &str) -> Result<String, Error> { | ||||
|         debug!("Running ssh command {command}"); | ||||
|         let mut channel = self.get_ssh_channel().await?; | ||||
|         channel.exec(true, command).await?; | ||||
|         wait_for_completion(&mut channel).await | ||||
|     } | ||||
| 
 | ||||
|     pub fn new( | ||||
|         host: (Ipv4Addr, u16), | ||||
|         credentials: SshCredentials, | ||||
|         ssh_config: Arc<Config>, | ||||
|     ) -> Self { | ||||
|         Self { | ||||
|             host, | ||||
|             credentials, | ||||
|             ssh_config, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| struct Client {} | ||||
| 
 | ||||
| #[async_trait] | ||||
| impl Handler for Client { | ||||
|     type Error = Error; | ||||
| 
 | ||||
|     async fn check_server_key( | ||||
|         &mut self, | ||||
|         _server_public_key: &key::PublicKey, | ||||
|     ) -> Result<bool, Self::Error> { | ||||
|         Ok(true) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| async fn wait_for_completion(channel: &mut Channel<Msg>) -> Result<String, Error> { | ||||
|     let mut output = Vec::new(); | ||||
| 
 | ||||
|     loop { | ||||
|         let Some(msg) = channel.wait().await else { | ||||
|             break; | ||||
|         }; | ||||
| 
 | ||||
|         match msg { | ||||
|             russh::ChannelMsg::ExtendedData { ref data, .. } | ||||
|             | russh::ChannelMsg::Data { ref data } => { | ||||
|                 output.append(&mut data.to_vec()); | ||||
|             } | ||||
|             russh::ChannelMsg::ExitStatus { exit_status } => { | ||||
|                 if exit_status != 0 { | ||||
|                     return Err(Error::Command(format!( | ||||
|                         "Command failed with exit status {exit_status}, output {}", | ||||
|                         String::from_utf8(output).unwrap_or_default() | ||||
|                     ))); | ||||
|                 } | ||||
|             } | ||||
|             russh::ChannelMsg::Success { .. } | ||||
|             | russh::ChannelMsg::WindowAdjusted { .. } | ||||
|             | russh::ChannelMsg::Eof { .. } => {} | ||||
|             _ => { | ||||
|                 return Err(Error::Unexpected(format!( | ||||
|                     "Russh got unexpected msg {msg:?}" | ||||
|                 ))) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     Ok(String::from_utf8(output).unwrap_or_default()) | ||||
| } | ||||
| @ -7,16 +7,37 @@ pub use error::Error; | ||||
| #[cfg(test)] | ||||
| mod test { | ||||
|     use config::SshConfigManager; | ||||
|     use opnsense_config_xml::StaticMap; | ||||
|     use russh::client; | ||||
|     use std::{net::Ipv4Addr, sync::Arc, time::Duration}; | ||||
| 
 | ||||
|     use crate::{ | ||||
|         config::{self, SshCredentials}, | ||||
|         config::{self, SshCredentials, SshOPNSenseShell}, | ||||
|         Config, | ||||
|     }; | ||||
|     use pretty_assertions::assert_eq; | ||||
| 
 | ||||
|     #[tokio::test] | ||||
|     async fn test_public_sdk() { | ||||
|         let mac = "11:22:33:44:55:66"; | ||||
|         let ip = Ipv4Addr::new(10, 100, 8, 200); | ||||
|         let hostname = "test_hostname"; | ||||
| 
 | ||||
|         remove_static_mapping(mac).await; | ||||
| 
 | ||||
|         // Make sure static mapping does not exist anymore
 | ||||
|         let static_mapping_removed = get_static_mappings().await; | ||||
|         assert!(!static_mapping_removed.iter().any(|e| e.mac == mac)); | ||||
| 
 | ||||
|         add_static_mapping(mac, ip, hostname).await; | ||||
| 
 | ||||
|         // Make sure static mapping has been added successfully
 | ||||
|         let static_mapping_added = get_static_mappings().await; | ||||
|         assert_eq!(static_mapping_added.len(), static_mapping_removed.len() + 1); | ||||
|         assert!(static_mapping_added.iter().any(|e| e.mac == mac)); | ||||
|     } | ||||
| 
 | ||||
|     async fn initialize_config() -> Config { | ||||
|         let config = Arc::new(client::Config { | ||||
|             inactivity_timeout: Some(Duration::from_secs(5)), | ||||
|             ..<_>::default() | ||||
| @ -27,19 +48,29 @@ mod test { | ||||
|             password: String::from("opnsense"), | ||||
|         }; | ||||
| 
 | ||||
|         let repo = | ||||
|             SshConfigManager::new((Ipv4Addr::new(192, 168, 5, 229), 22), credentials, config); | ||||
|         let shell = Arc::new(SshOPNSenseShell::new( | ||||
|             (Ipv4Addr::new(192, 168, 5, 229), 22), | ||||
|             credentials, | ||||
|             config, | ||||
|         )); | ||||
|         let manager = Arc::new(SshConfigManager::new(shell.clone())); | ||||
|         Config::new(manager, shell).await.unwrap() | ||||
|     } | ||||
| 
 | ||||
|         let mut config = Config::new(Box::new(repo)).await.unwrap(); | ||||
|         config | ||||
|             .dhcp() | ||||
|             .add_static_mapping( | ||||
|                 "11:22:33:44:55:66", | ||||
|                 Ipv4Addr::new(10, 100, 8, 200), | ||||
|                 "test_hostname", | ||||
|             ) | ||||
|             .unwrap(); | ||||
|     async fn get_static_mappings() -> Vec<StaticMap> { | ||||
|         let mut config = initialize_config().await; | ||||
|         config.dhcp().get_static_mappings().await.unwrap() | ||||
|     } | ||||
| 
 | ||||
|     async fn add_static_mapping(mac: &str, ip: Ipv4Addr, hostname: &str) { | ||||
|         let mut config = initialize_config().await; | ||||
|         config.dhcp().add_static_mapping(mac, ip, hostname).unwrap(); | ||||
|         config.apply().await.unwrap(); | ||||
|     } | ||||
| 
 | ||||
|     async fn remove_static_mapping(mac: &str) { | ||||
|         let mut config = initialize_config().await; | ||||
|         config.dhcp().remove_static_mapping(mac); | ||||
|         config.apply().await.unwrap(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,12 +1,18 @@ | ||||
| use opnsense_config_xml::MaybeString; | ||||
| use opnsense_config_xml::Range; | ||||
| use opnsense_config_xml::StaticMap; | ||||
| use std::cmp::Ordering; | ||||
| use std::net::Ipv4Addr; | ||||
| use std::sync::Arc; | ||||
| 
 | ||||
| use opnsense_config_xml::OPNsense; | ||||
| 
 | ||||
| use crate::config::OPNsenseShell; | ||||
| use crate::Error; | ||||
| 
 | ||||
| pub struct DhcpConfig<'a> { | ||||
|     opnsense: &'a mut OPNsense, | ||||
|     opnsense_shell: Arc<dyn OPNsenseShell>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| @ -39,8 +45,27 @@ impl std::fmt::Display for DhcpError { | ||||
| impl std::error::Error for DhcpError {} | ||||
| 
 | ||||
| impl<'a> DhcpConfig<'a> { | ||||
|     pub fn new(opnsense: &'a mut OPNsense) -> Self { | ||||
|         Self { opnsense } | ||||
|     pub fn new(opnsense: &'a mut OPNsense, opnsense_shell: Arc<dyn OPNsenseShell>) -> Self { | ||||
|         Self { | ||||
|             opnsense, | ||||
|             opnsense_shell, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub fn remove_static_mapping(&mut self, mac: &str) { | ||||
|         let lan_dhcpd = self.get_lan_dhcpd(); | ||||
|         lan_dhcpd.staticmaps.retain(|static_entry| static_entry.mac != mac); | ||||
|     } | ||||
| 
 | ||||
|     fn get_lan_dhcpd(&mut self) -> &mut opnsense_config_xml::DhcpInterface { | ||||
|         &mut self | ||||
|             .opnsense | ||||
|             .dhcpd | ||||
|             .elements | ||||
|             .iter_mut() | ||||
|             .find(|(name, _config)| return name == "lan") | ||||
|             .expect("Interface lan should have dhcpd activated") | ||||
|             .1 | ||||
|     } | ||||
| 
 | ||||
|     pub fn add_static_mapping( | ||||
| @ -51,16 +76,9 @@ impl<'a> DhcpConfig<'a> { | ||||
|     ) -> Result<(), DhcpError> { | ||||
|         let mac = mac.to_string(); | ||||
|         let hostname = hostname.to_string(); | ||||
|         let lan_dhcpd = &mut self | ||||
|             .opnsense | ||||
|             .dhcpd | ||||
|             .elements | ||||
|             .iter_mut() | ||||
|             .find(|(name, _config)| return name == "lan") | ||||
|             .expect("Interface lan should have dhcpd activated") | ||||
|             .1; | ||||
| 
 | ||||
|         let lan_dhcpd = self.get_lan_dhcpd(); | ||||
|         let range = &lan_dhcpd.range; | ||||
|         let existing_mappings: &mut Vec<StaticMap> = &mut lan_dhcpd.staticmaps; | ||||
| 
 | ||||
|         if !Self::is_valid_mac(&mac) { | ||||
|             return Err(DhcpError::InvalidMacAddress(mac)); | ||||
| @ -70,8 +88,6 @@ impl<'a> DhcpConfig<'a> { | ||||
|             return Err(DhcpError::IpAddressOutOfRange(ipaddr.to_string())); | ||||
|         } | ||||
| 
 | ||||
|         let existing_mappings: &mut Vec<StaticMap> = &mut lan_dhcpd.staticmaps; | ||||
| 
 | ||||
|         if existing_mappings.iter().any(|m| { | ||||
|             m.ipaddr | ||||
|                 .parse::<Ipv4Addr>() | ||||
| @ -128,6 +144,35 @@ impl<'a> DhcpConfig<'a> { | ||||
|             return false; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub async fn get_static_mappings(&self) -> Result<Vec<StaticMap>, Error> { | ||||
|         let list_static_output = self | ||||
|             .opnsense_shell | ||||
|             .exec("configctl dhcpd list static") | ||||
|             .await?; | ||||
| 
 | ||||
|         let value: serde_json::Value = serde_json::from_str(&list_static_output).expect(&format!( | ||||
|             "Got invalid json from configctl {list_static_output}" | ||||
|         )); | ||||
|         let static_maps = value["dhcpd"] | ||||
|             .as_array() | ||||
|             .ok_or(Error::Command(format!( | ||||
|                 "Invalid DHCP data from configctl command, got {list_static_output}" | ||||
|             )))? | ||||
|             .iter() | ||||
|             .map(|entry| StaticMap { | ||||
|                 mac: entry["mac"].as_str().unwrap_or_default().to_string(), | ||||
|                 ipaddr: entry["ipaddr"].as_str().unwrap_or_default().to_string(), | ||||
|                 hostname: entry["hostname"].as_str().unwrap_or_default().to_string(), | ||||
|                 descr: entry["descr"].as_str().map(MaybeString::from), | ||||
|                 winsserver: MaybeString::default(), | ||||
|                 dnsserver: MaybeString::default(), | ||||
|                 ntpserver: MaybeString::default(), | ||||
|             }) | ||||
|             .collect(); | ||||
| 
 | ||||
|         Ok(static_maps) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user