This commit adds `serde` dependency and derives `Serialize` trait for `Score` types. This is necessary for serialization and deserialization of these types, which is required to display Scores to various user interfaces - Added `serde` dependency to `harmony_types/Cargo.toml`. - Added `serde::Serialize` derive macro to `MacAddress` in `harmony_types/src/lib.rs`. - Added `serde::Serialize` derive macro to `Config` in `opnsense-config/src/config/config.rs`. - Added `serde::Serialize` derive macro to `Score` in `harmony_types/src/lib.rs`. - Added `serde::Serialize` derive macro to `Config` and `Score` in relevant modules. - Added placeholder `todo!()` implementations for `serialize` methods. These will be implemented in future commits.
333 lines
11 KiB
Rust
333 lines
11 KiB
Rust
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<dyn ConfigManager>,
|
|
shell: Arc<dyn OPNsenseShell>,
|
|
}
|
|
|
|
impl Serialize for Config {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
todo!()
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub async fn new(
|
|
repository: Arc<dyn ConfigManager>,
|
|
shell: Arc<dyn OPNsenseShell>,
|
|
) -> Result<Self, Error> {
|
|
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<String, Error> {
|
|
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
|
|
/// <opnsense>
|
|
/// <system>
|
|
/// <firmware>
|
|
/// <plugins>os-haproxy,os-iperf,os-cpu-microcode-intel</plugins>
|
|
/// </firmware>
|
|
/// </system>
|
|
/// </opnsense>
|
|
/// ```
|
|
///
|
|
/// 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<u16>,
|
|
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<dyn ConfigManager>) -> Result<OPNsense, Error> {
|
|
let xml = repository.load_as_str().await?;
|
|
trace!("xml {}", xml);
|
|
|
|
Ok(OPNsense::from(xml))
|
|
}
|
|
|
|
pub async fn run_command(&self, command: &str) -> Result<String, Error> {
|
|
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::<Vec<_>>()].sort();
|
|
let serialized_sorted = vec![config_file_str.lines().collect::<Vec<_>>()].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 "));
|
|
}
|
|
}
|