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:
Jean-Gabriel Gill-Couture
2024-11-23 15:07:04 -05:00
parent b14d0ab686
commit d30e909b83
10 changed files with 461 additions and 198 deletions

View File

@@ -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"

View File

@@ -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",

View File

@@ -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())
}

View File

@@ -1,4 +1,6 @@
mod config;
mod manager;
mod shell;
pub use manager::*;
pub use config::*;
pub use shell::*;

View 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");
}
}

View 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())
}

View File

@@ -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();
}
}

View File

@@ -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)]