From d30e909b83287bd236629bc3a4aaacee0d5b2b74 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 23 Nov 2024 15:07:04 -0500 Subject: [PATCH] feat(opnsense-config): Public API now complete for dhcp add_static_mapping and remove_static_mapping, not perfect but good enough to move forward --- harmony-rs/Cargo.lock | 151 ++++++++++++++- harmony-rs/opnsense-config-xml/src/lib.rs | 1 + harmony-rs/opnsense-config/Cargo.toml | 2 + .../opnsense-config/src/config/config.rs | 33 ++-- .../opnsense-config/src/config/manager/ssh.rs | 176 ++---------------- harmony-rs/opnsense-config/src/config/mod.rs | 2 + .../opnsense-config/src/config/shell/mod.rs | 27 +++ .../opnsense-config/src/config/shell/ssh.rs | 141 ++++++++++++++ harmony-rs/opnsense-config/src/lib.rs | 55 ++++-- .../opnsense-config/src/modules/dhcp.rs | 71 +++++-- 10 files changed, 461 insertions(+), 198 deletions(-) create mode 100644 harmony-rs/opnsense-config/src/config/shell/mod.rs create mode 100644 harmony-rs/opnsense-config/src/config/shell/ssh.rs diff --git a/harmony-rs/Cargo.lock b/harmony-rs/Cargo.lock index 2e70148..6831b38 100644 --- a/harmony-rs/Cargo.lock +++ b/harmony-rs/Cargo.lock @@ -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" diff --git a/harmony-rs/opnsense-config-xml/src/lib.rs b/harmony-rs/opnsense-config-xml/src/lib.rs index aa934ca..f210ae5 100644 --- a/harmony-rs/opnsense-config-xml/src/lib.rs +++ b/harmony-rs/opnsense-config-xml/src/lib.rs @@ -1,3 +1,4 @@ mod xml_utils; mod data; pub use data::*; +pub use yaserde::MaybeString; diff --git a/harmony-rs/opnsense-config/Cargo.toml b/harmony-rs/opnsense-config/Cargo.toml index 97c74f0..b75f146 100644 --- a/harmony-rs/opnsense-config/Cargo.toml +++ b/harmony-rs/opnsense-config/Cargo.toml @@ -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" diff --git a/harmony-rs/opnsense-config/src/config/config.rs b/harmony-rs/opnsense-config/src/config/config.rs index f87823e..29dbd29 100644 --- a/harmony-rs/opnsense-config/src/config/config.rs +++ b/harmony-rs/opnsense-config/src/config/config.rs @@ -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, + repository: Arc, + shell: Arc, } impl Config { - pub async fn new(repository: Box) -> Result { + pub async fn new( + repository: Arc, + shell: Arc, + ) -> Result { 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", diff --git a/harmony-rs/opnsense-config/src/config/manager/ssh.rs b/harmony-rs/opnsense-config/src/config/manager/ssh.rs index 2fe4e81..a32298b 100644 --- a/harmony-rs/opnsense-config/src/config/manager/ssh.rs +++ b/harmony-rs/opnsense-config/src/config/manager/ssh.rs @@ -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 { - 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, - credentials: SshCredentials, - host: (Ipv4Addr, u16), + opnsense_shell: Arc, } impl SshConfigManager { - pub fn new( - host: (Ipv4Addr, u16), - credentials: SshCredentials, - ssh_config: Arc, - ) -> Self { - Self { - ssh_config, - credentials, - host, - } + pub fn new(opnsense_shell: Arc) -> Self { + Self { opnsense_shell } } } impl SshConfigManager { - async fn get_ssh_channel(&self) -> Result, 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 { - 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 { 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 { 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 { 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 { - 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 { - let mut channel = self.get_ssh_channel().await?; - - channel.exec(true, "cat /conf/config.xml").await?; - let mut output: Vec = 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) -> Result { - 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()) -} diff --git a/harmony-rs/opnsense-config/src/config/mod.rs b/harmony-rs/opnsense-config/src/config/mod.rs index 10751ce..e84bee9 100644 --- a/harmony-rs/opnsense-config/src/config/mod.rs +++ b/harmony-rs/opnsense-config/src/config/mod.rs @@ -1,4 +1,6 @@ mod config; mod manager; +mod shell; pub use manager::*; pub use config::*; +pub use shell::*; diff --git a/harmony-rs/opnsense-config/src/config/shell/mod.rs b/harmony-rs/opnsense-config/src/config/shell/mod.rs new file mode 100644 index 0000000..3a644a6 --- /dev/null +++ b/harmony-rs/opnsense-config/src/config/shell/mod.rs @@ -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; + async fn write_content_to_temp_file(&self, content: &str) -> Result; +} + +#[cfg(test)] +#[derive(Debug)] +pub struct DummyOPNSenseShell; + +#[cfg(test)] +#[async_trait] +impl OPNsenseShell for DummyOPNSenseShell { + async fn exec(&self, _command: &str) -> Result { + unimplemented!("This is a dummy implementation"); + } + async fn write_content_to_temp_file(&self, _content: &str) -> Result { + unimplemented!("This is a dummy implementation"); + } +} diff --git a/harmony-rs/opnsense-config/src/config/shell/ssh.rs b/harmony-rs/opnsense-config/src/config/shell/ssh.rs new file mode 100644 index 0000000..62ff391 --- /dev/null +++ b/harmony-rs/opnsense-config/src/config/shell/ssh.rs @@ -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, +} + +#[async_trait] +impl OPNsenseShell for SshOPNSenseShell { + async fn exec(&self, command: &str) -> Result { + self.run_command(command).await + } + + async fn write_content_to_temp_file(&self, content: &str) -> Result { + 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, 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 { + 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, + ) -> 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 { + Ok(true) + } +} + +async fn wait_for_completion(channel: &mut Channel) -> Result { + 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()) +} diff --git a/harmony-rs/opnsense-config/src/lib.rs b/harmony-rs/opnsense-config/src/lib.rs index c835f06..00a411b 100644 --- a/harmony-rs/opnsense-config/src/lib.rs +++ b/harmony-rs/opnsense-config/src/lib.rs @@ -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 { + 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(); } } diff --git a/harmony-rs/opnsense-config/src/modules/dhcp.rs b/harmony-rs/opnsense-config/src/modules/dhcp.rs index 983c1d5..a337cac 100644 --- a/harmony-rs/opnsense-config/src/modules/dhcp.rs +++ b/harmony-rs/opnsense-config/src/modules/dhcp.rs @@ -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, } #[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) -> 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 = &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 = &mut lan_dhcpd.staticmaps; - if existing_mappings.iter().any(|m| { m.ipaddr .parse::() @@ -128,6 +144,35 @@ impl<'a> DhcpConfig<'a> { return false; } } + + pub async fn get_static_mappings(&self) -> Result, 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)]