harmony/harmony-rs/opnsense-config/src/config/config.rs
Jean-Gabriel Gill-Couture 0b30d82793 feat(harmony): add OPNSense shell command execution score
Introduces a new `OPNSenseShellCommandScore` to execute shell commands on an OPNSense device within the Harmony framework. This allows for custom command execution as part of the scoring and interpretation process, enhancing the flexibility and functionality of the system.
2025-02-04 16:39:49 -05:00

323 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 super::{ConfigManager, OPNsenseShell};
#[derive(Debug)]
pub struct Config {
opnsense: OPNsense,
repository: Arc<dyn ConfigManager>,
shell: Arc<dyn OPNsenseShell>,
}
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 "));
}
}