use log::info; 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)] pub enum DhcpError { InvalidMacAddress(String), InvalidIpAddress(String), IpAddressAlreadyMapped(String), MacAddressAlreadyMapped(String), IpAddressOutOfRange(String), } impl std::fmt::Display for DhcpError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DhcpError::InvalidMacAddress(mac) => write!(f, "Invalid MAC address format: {}", mac), DhcpError::InvalidIpAddress(ip) => write!(f, "Invalid IP address format: {}", ip), DhcpError::IpAddressAlreadyMapped(ip) => { write!(f, "IP address {} is already mapped", ip) } DhcpError::MacAddressAlreadyMapped(mac) => { write!(f, "MAC address {} is already mapped", mac) } DhcpError::IpAddressOutOfRange(ip) => { write!(f, "IP address {} is out of interface range", ip) } } } } impl std::error::Error for DhcpError {} impl<'a> DhcpConfig<'a> { 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( &mut self, mac: &str, ipaddr: Ipv4Addr, hostname: &str, ) -> Result<(), DhcpError> { let mac = mac.to_string(); let hostname = hostname.to_string(); let lan_dhcpd = self.get_lan_dhcpd(); let existing_mappings: &mut Vec = &mut lan_dhcpd.staticmaps; if !Self::is_valid_mac(&mac) { return Err(DhcpError::InvalidMacAddress(mac)); } // TODO verify if address is in subnet range // This check here does not do what we want to do, as we want to assign static leases // outside of the dynamic DHCP pool // let range = &lan_dhcpd.range; // if !Self::is_ip_in_range(&ipaddr, range) { // return Err(DhcpError::IpAddressOutOfRange(ipaddr.to_string())); // } if existing_mappings.iter().any(|m| { m.ipaddr .parse::() .expect("Mapping contains invalid ipv4") == ipaddr && m.mac == mac }) { info!( "Mapping already exists for {} [{}], skipping", ipaddr.to_string(), mac ); return Ok(()); } if existing_mappings.iter().any(|m| { m.ipaddr .parse::() .expect("Mapping contains invalid ipv4") == ipaddr }) { return Err(DhcpError::IpAddressAlreadyMapped(ipaddr.to_string())); } if existing_mappings.iter().any(|m| m.mac == mac) { return Err(DhcpError::MacAddressAlreadyMapped(mac)); } let static_map = StaticMap { mac, ipaddr: ipaddr.to_string(), hostname, descr: Default::default(), winsserver: Default::default(), dnsserver: Default::default(), ntpserver: Default::default(), }; existing_mappings.push(static_map); Ok(()) } fn is_valid_mac(mac: &str) -> bool { let parts: Vec<&str> = mac.split(':').collect(); if parts.len() != 6 { return false; } parts .iter() .all(|part| part.len() <= 2 && part.chars().all(|c| c.is_ascii_hexdigit())) } fn is_ip_in_range(ip: &Ipv4Addr, range: &Range) -> bool { let range_start = range .from .parse::() .expect("Invalid DHCP range start"); let range_end = range.to.parse::().expect("Invalid DHCP range to"); let start_compare = range_start.cmp(ip); let end_compare = range_end.cmp(ip); if (Ordering::Less == start_compare || Ordering::Equal == start_compare) && (Ordering::Greater == end_compare || Ordering::Equal == end_compare) { return true; } else { 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) } pub fn enable_netboot(&mut self) { self.get_lan_dhcpd().netboot = Some(1); } pub fn set_next_server(&mut self, ip: Ipv4Addr) { self.enable_netboot(); self.get_lan_dhcpd().nextserver = Some(ip.to_string()); self.get_lan_dhcpd().tftp = Some(ip.to_string()); } pub fn set_boot_filename(&mut self, boot_filename: &str) { self.enable_netboot(); self.get_lan_dhcpd().filename64 = Some(boot_filename.to_string()); self.get_lan_dhcpd().bootfilename = Some(boot_filename.to_string()); } } #[cfg(test)] mod test { use super::*; use pretty_assertions::assert_eq; use std::net::Ipv4Addr; #[test] fn test_ip_in_range() { let range = Range { from: "192.168.1.100".to_string(), to: "192.168.1.200".to_string(), }; // Test IP within range let ip = "192.168.1.150".parse::().unwrap(); assert_eq!(DhcpConfig::is_ip_in_range(&ip, &range), true); // Test IP at start of range let ip = "192.168.1.100".parse::().unwrap(); assert_eq!(DhcpConfig::is_ip_in_range(&ip, &range), true); // Test IP at end of range let ip = "192.168.1.200".parse::().unwrap(); assert_eq!(DhcpConfig::is_ip_in_range(&ip, &range), true); // Test IP before range let ip = "192.168.1.99".parse::().unwrap(); assert_eq!(DhcpConfig::is_ip_in_range(&ip, &range), false); // Test IP after range let ip = "192.168.1.201".parse::().unwrap(); assert_eq!(DhcpConfig::is_ip_in_range(&ip, &range), false); } }