use std::sync::Arc; use crate::{ config::{SshConfigManager, SshCredentials, SshOPNSenseShell}, error::Error, modules::{ caddy::CaddyConfig, dhcp::DhcpConfig, dns::DnsConfig, load_balancer::LoadBalancerConfig, tftp::TftpConfig, }, }; use log::{debug, info, trace, warn}; use opnsense_config_xml::OPNsense; use russh::client; use serde::Serialize; use super::{ConfigManager, OPNsenseShell}; #[derive(Debug)] pub struct Config { opnsense: OPNsense, repository: Arc, shell: Arc, } impl Serialize for Config { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { todo!() } } impl Config { pub async fn new( repository: Arc, shell: Arc, ) -> Result { Ok(Self { opnsense: Self::get_opnsense_instance(repository.clone()).await?, repository, shell, }) } pub fn dhcp(&mut self) -> DhcpConfig { DhcpConfig::new(&mut self.opnsense, self.shell.clone()) } pub fn dns(&mut self) -> DnsConfig { DnsConfig::new(&mut self.opnsense) } pub fn tftp(&mut self) -> TftpConfig { TftpConfig::new(&mut self.opnsense, self.shell.clone()) } pub fn caddy(&mut self) -> CaddyConfig { CaddyConfig::new(&mut self.opnsense, self.shell.clone()) } pub fn load_balancer(&mut self) -> LoadBalancerConfig { LoadBalancerConfig::new(&mut self.opnsense, self.shell.clone()) } pub async fn upload_files(&self, source: &str, destination: &str) -> Result { self.shell.upload_folder(source, destination).await } /// Checks in config file if system.firmware.plugins csv field contains the specified package /// name. /// /// Given this /// ```xml /// /// /// /// os-haproxy,os-iperf,os-cpu-microcode-intel /// /// /// /// ``` /// /// is_package_installed("os-cpu"); // false /// is_package_installed("os-haproxy"); // true /// is_package_installed("os-cpu-microcode-intel"); // true pub fn is_package_installed(&self, package_name: &str) -> bool { match &self.opnsense.system.firmware.plugins.content { Some(plugins) => is_package_in_csv(plugins, package_name), None => false, } } // Here maybe we should take ownership of `mut self` instead of `&mut self` // I don't think there can be faulty pointers to previous versions of the config but I have a // hard time wrapping my head around it right now : // - the caller has a mutable reference to us // - caller gets a reference to a piece of configuration (.haproxy.general.servers[0]) // - caller calls install_package wich reloads the config from remote // - haproxy.general.servers[0] does not exist anymore // - broken? // // Although I did not try explicitely the above workflow so maybe rust prevents taking a // read-only reference across the &mut call pub async fn install_package(&mut self, package_name: &str) -> Result<(), Error> { info!("Installing opnsense package {package_name}"); self.check_pkg_opnsense_org_connection().await?; let output = self.shell .exec(&format!("/bin/sh -c \"export LOCKFILE=/dev/stdout && /usr/local/opnsense/scripts/firmware/install.sh {package_name}\"")) .await?; info!("Installation output {output}"); self.reload_config().await?; let is_installed = self.is_package_installed(package_name); debug!("Verifying package installed successfully {is_installed}"); if is_installed { info!("Installation successful for {package_name}"); Ok(()) } else { let msg = format!("Package installation failed for {package_name}, see above logs"); warn!("{}", msg); Err(Error::Unexpected(msg)) } } pub async fn check_pkg_opnsense_org_connection(&mut self) -> Result<(), Error> { let pkg_url = "https://pkg.opnsense.org"; info!("Verifying connection to {pkg_url}"); let output = self .shell .exec(&format!("/bin/sh -c \"curl -v {pkg_url}\"")) .await?; info!("{}", output); Ok(()) } async fn reload_config(&mut self) -> Result<(), Error> { info!("Reloading opnsense live config"); self.opnsense = Self::get_opnsense_instance(self.repository.clone()).await?; Ok(()) } pub async fn restart_dns(&self) -> Result<(), Error> { self.shell.exec("configctl unbound restart").await?; Ok(()) } /// Save the config to the repository. This method is meant NOT to reload services, only save /// the config to the live file/database and perhaps take a backup when relevant. pub async fn save(&self) -> Result<(), Error> { self.repository.save_config(&self.opnsense.to_xml()).await } /// Save the configuration and reload all services. Be careful with this one as it will cause /// downtime in many cases, such as a PPPoE renegociation pub async fn apply(&self) -> Result<(), Error> { self.repository .apply_new_config(&self.opnsense.to_xml()) .await } pub async fn from_credentials( ipaddr: std::net::IpAddr, port: Option, username: &str, password: &str, ) -> Self { let config = Arc::new(client::Config { inactivity_timeout: None, ..<_>::default() }); let credentials = SshCredentials::Password { username: String::from(username), password: String::from(password), }; let port = port.unwrap_or(22); let shell = Arc::new(SshOPNSenseShell::new((ipaddr, port), credentials, config)); let manager = Arc::new(SshConfigManager::new(shell.clone())); Config::new(manager, shell).await.unwrap() } async fn get_opnsense_instance(repository: Arc) -> Result { let xml = repository.load_as_str().await?; trace!("xml {}", xml); Ok(OPNsense::from(xml)) } pub async fn run_command(&self, command: &str) -> Result { self.shell.exec(command).await } } #[cfg(test)] mod tests { use crate::config::{DummyOPNSenseShell, LocalFileConfigManager}; use crate::modules::dhcp::DhcpConfig; use std::fs; use std::net::Ipv4Addr; use super::*; use pretty_assertions::assert_eq; use std::path::PathBuf; #[tokio::test] async fn test_load_config_from_local_file() { for path in vec![ "src/tests/data/config-opnsense-25.1.xml", "src/tests/data/config-vm-test.xml", "src/tests/data/config-structure.xml", "src/tests/data/config-full-1.xml", ] { let mut test_file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); test_file_path.push(path); let config_file_path = test_file_path.to_str().unwrap().to_string(); println!("File path {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, shell) .await .expect("Failed to load config"); println!("Config {:?}", config); let serialized = config.opnsense.to_xml(); fs::write("/tmp/serialized.xml", &serialized).unwrap(); // Since the order of all fields is not always the same in opnsense config files // I think it is good enough to have exactly the same amount of the same lines let config_file_str_sorted = vec![config_file_str.lines().collect::>()].sort(); let serialized_sorted = vec![config_file_str.lines().collect::>()].sort(); assert_eq!(config_file_str_sorted, serialized_sorted); } } #[tokio::test] async fn test_add_dhcpd_static_entry() { let mut test_file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); test_file_path.push("src/tests/data/config-structure.xml"); let config_file_path = test_file_path.to_str().unwrap().to_string(); println!("File path {config_file_path}"); 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, shell); dhcp_config .add_static_mapping( "00:00:00:00:00:00", Ipv4Addr::new(192, 168, 20, 100), "hostname", ) .expect("Should add static mapping"); let serialized = config.opnsense.to_xml(); fs::write("/tmp/serialized.xml", &serialized).unwrap(); let mut test_file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); test_file_path.push("src/tests/data/config-structure-with-dhcp-staticmap-entry.xml"); 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 expected_config_file_str = repository.load_as_str().await.unwrap(); assert_eq!(expected_config_file_str, serialized); } } /// Checks if a given package name exists in a comma-separated list of packages. /// /// # Arguments /// /// * `csv_string` - A string containing comma-separated package names. /// * `package_name` - The package name to search for. /// /// # Returns /// /// * `true` if the package name is found in the CSV string, `false` otherwise. fn is_package_in_csv(csv_string: &str, package_name: &str) -> bool { package_name.len() > 0 && csv_string.split(',').any(|pkg| pkg.trim() == package_name) } #[cfg(test)] mod tests_2 { use super::*; #[test] fn test_is_package_in_csv() { let csv_string = "os-haproxy,os-iperf,os-cpu-microcode-intel"; assert!(is_package_in_csv(csv_string, "os-haproxy")); assert!(is_package_in_csv(csv_string, "os-iperf")); assert!(is_package_in_csv(csv_string, "os-cpu-microcode-intel")); assert!(!is_package_in_csv(csv_string, "os-cpu")); assert!(!is_package_in_csv(csv_string, "non-existent-package")); } #[test] fn test_is_package_in_csv_empty() { let csv_string = ""; assert!(!is_package_in_csv(csv_string, "os-haproxy")); assert!(!is_package_in_csv(csv_string, "")); } #[test] fn test_is_package_in_csv_whitespace() { let csv_string = " os-haproxy , os-iperf , os-cpu-microcode-intel "; assert!(is_package_in_csv(csv_string, "os-haproxy")); assert!(is_package_in_csv(csv_string, "os-iperf")); assert!(is_package_in_csv(csv_string, "os-cpu-microcode-intel")); assert!(!is_package_in_csv(csv_string, " os-haproxy ")); } }