// dnsmasq.rs use crate::modules::dhcp::DhcpError; use log::{debug, info, warn}; use opnsense_config_xml::dnsmasq::{DhcpRange, DnsMasq, DnsmasqHost}; // Assuming DhcpRange is defined in opnsense_config_xml::dnsmasq use opnsense_config_xml::{MaybeString, StaticMap}; use std::collections::HashSet; use std::net::Ipv4Addr; use std::sync::Arc; use uuid::Uuid; use opnsense_config_xml::OPNsense; use crate::config::OPNsenseShell; use crate::Error; pub struct DhcpConfigDnsMasq<'a> { opnsense: &'a mut OPNsense, opnsense_shell: Arc, } const DNS_MASQ_PXE_CONFIG_FILE: &str = "/usr/local/etc/dnsmasq.conf.d/pxe.conf"; impl<'a> DhcpConfigDnsMasq<'a> { pub fn new(opnsense: &'a mut OPNsense, opnsense_shell: Arc) -> Self { Self { opnsense, opnsense_shell, } } /// Removes a MAC address from a static mapping. /// If the mapping has no other MAC addresses associated with it, the entire host entry is removed. pub fn remove_static_mapping(&mut self, mac_to_remove: &str) { let dnsmasq = self.get_dnsmasq(); // Update hwaddr fields for hosts that contain the MAC, removing it from the comma-separated list. for host in dnsmasq.hosts.iter_mut() { let mac = host.hwaddr.content_string(); let original_macs: Vec<&str> = mac.split(',').collect(); if original_macs .iter() .any(|m| m.eq_ignore_ascii_case(mac_to_remove)) { let updated_macs: Vec<&str> = original_macs .into_iter() .filter(|m| !m.eq_ignore_ascii_case(mac_to_remove)) .collect(); host.hwaddr = updated_macs.join(",").into(); } } // Remove any host entries that no longer have any MAC addresses. dnsmasq .hosts .retain(|host_entry| !host_entry.hwaddr.content_string().is_empty()); } /// Retrieves a mutable reference to the DnsMasq configuration. /// This is located in the section of the OPNsense config. fn get_dnsmasq(&mut self) -> &mut DnsMasq { self.opnsense .dnsmasq .as_mut() .expect("Dnsmasq config must be initialized") } /// Adds or updates a static DHCP mapping. /// /// This function implements specific logic to handle existing entries: /// - If no host exists for the given IP or hostname, a new entry is created. /// - If exactly one host exists for the IP and/or hostname, the new MAC is appended to it. /// - It will panic if the IP and hostname exist but point to two different host entries, /// as this represents an unresolvable conflict. /// - It will also panic if multiple entries are found for the IP or hostname, indicating an /// ambiguous state. pub fn add_static_mapping( &mut self, mac: &str, ipaddr: Ipv4Addr, hostname: &str, ) -> Result<(), DhcpError> { if !Self::is_valid_mac(mac) { return Err(DhcpError::InvalidMacAddress(mac.to_string())); } let ip_str = ipaddr.to_string(); let hosts = &mut self.get_dnsmasq().hosts; let ip_indices: Vec = hosts .iter() .enumerate() .filter(|(_, h)| h.ip.content_string() == ip_str) .map(|(i, _)| i) .collect(); let hostname_indices: Vec = hosts .iter() .enumerate() .filter(|(_, h)| h.host == hostname) .map(|(i, _)| i) .collect(); let ip_set: HashSet = ip_indices.iter().cloned().collect(); let hostname_set: HashSet = hostname_indices.iter().cloned().collect(); if !ip_indices.is_empty() && !hostname_indices.is_empty() && ip_set.intersection(&hostname_set).count() == 0 { panic!( "Configuration conflict: IP {} and hostname '{}' exist, but in different static host entries.", ipaddr, hostname ); } let mut all_indices: Vec<&usize> = ip_set.union(&hostname_set).collect(); all_indices.sort(); match all_indices.len() { 0 => { info!( "Creating new static host for {} ({}) with MAC {}", hostname, ipaddr, mac ); let new_host = DnsmasqHost { uuid: Uuid::new_v4().to_string(), host: hostname.to_string(), ip: ip_str.into(), hwaddr: mac.to_string().into(), local: MaybeString::from("1"), ignore: Some(0), ..Default::default() }; hosts.push(new_host); } 1 => { let host_index = *all_indices[0]; let host_to_modify = &mut hosts[host_index]; let host_to_modify_ip = host_to_modify.ip.content_string(); if host_to_modify_ip != ip_str { warn!( "Hostname '{}' already exists with a different IP ({}). Appending MAC {}.", hostname, host_to_modify_ip, mac ); } else if host_to_modify.host != hostname { warn!( "IP {} already exists with a different hostname ('{}'). Appending MAC {}.", ipaddr, host_to_modify.host, mac ); } if !host_to_modify .hwaddr .content_string() .split(',') .any(|m| m.eq_ignore_ascii_case(mac)) { info!( "Appending MAC {} to existing static host for {} ({})", mac, host_to_modify.host, host_to_modify_ip ); let mut updated_macs = host_to_modify.hwaddr.content_string().to_string(); updated_macs.push(','); updated_macs.push_str(mac); host_to_modify.hwaddr = updated_macs.into(); } else { info!( "MAC {} already present in static host entry for {} ({}). No changes made.", mac, host_to_modify.host, host_to_modify_ip ); } } _ => { panic!( "Configuration conflict: Found multiple host entries matching IP {} and/or hostname '{}'. Cannot resolve automatically.", ipaddr, hostname ); } } Ok(()) } /// Helper function to validate a MAC address format. 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())) } /// Retrieves the list of current static mappings by shelling out to `configctl`. /// This provides the real-time state from the running system. pub async fn get_static_mappings(&self) -> Result, Error> { // Note: This command is for the 'dhcpd' service. If dnsmasq uses a different command // or key, this will need to be adjusted. 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) .unwrap_or_else(|_| panic!("Got invalid json from configctl {list_static_output}")); // The JSON output key might be 'dhcpd' even when dnsmasq is the backend. 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), ..Default::default() }) .collect(); Ok(static_maps) } pub async fn set_dhcp_range(&mut self, start: &str, end: &str) -> Result<(), DhcpError> { let dnsmasq = self.get_dnsmasq(); let ranges = &mut dnsmasq.dhcp_ranges; // Assuming DnsMasq has dhcp_ranges: Vec // Find existing range for "lan" interface if let Some(range) = ranges .iter_mut() .find(|r| r.interface == Some("lan".to_string())) { // Update existing range range.start_addr = Some(start.to_string()); range.end_addr = Some(end.to_string()); } else { // Create new range let new_range = DhcpRange { uuid: Some(Uuid::new_v4().to_string()), interface: Some("lan".to_string()), start_addr: Some(start.to_string()), end_addr: Some(end.to_string()), domain_type: Some("range".to_string()), nosync: Some(0), ..Default::default() }; ranges.push(new_range); } Ok(()) } pub async fn set_pxe_options( &self, tftp_ip: Option, bios_filename: String, efi_filename: String, ipxe_filename: String, ) -> Result<(), DhcpError> { // OPNsense does not support negative tags via its API for dnsmasq, and the required // logic is complex. Therefore, we write a configuration file directly to the // dnsmasq.conf.d directory to achieve the desired PXE boot behavior. let tftp_str = tftp_ip.map_or(String::new(), |i| format!(",{i},{i}")); let config = format!( " # Add tag ipxe to dhcp requests with user class (77) = iPXE dhcp-match=set:ipxe,77,iPXE # Add tag bios to dhcp requests with arch (93) = 0 dhcp-match=set:bios,93,0 # Add tag efi to dhcp requests with arch (93) = 7 dhcp-match=set:efi,93,7 # Provide ipxe efi file to uefi but NOT ipxe clients dhcp-boot=tag:efi,tag:!ipxe,{efi_filename}{tftp_str} # Provide ipxe boot script to ipxe clients dhcp-boot=tag:ipxe,{ipxe_filename}{tftp_str} # Provide undionly to legacy bios clients dhcp-boot=tag:bios,{bios_filename}{tftp_str} " ); info!("Writing configuration file to {DNS_MASQ_PXE_CONFIG_FILE}"); debug!("Content:\n{config}"); self.opnsense_shell .write_content_to_file(&config, DNS_MASQ_PXE_CONFIG_FILE) .await .map_err(|e| { DhcpError::Configuration(format!( "Could not configure pxe for dhcp because of : {e}" )) })?; info!("Restarting dnsmasq to apply changes"); self.opnsense_shell .exec("configctl dnsmasq restart") .await .map_err(|e| DhcpError::Configuration(format!("Restarting dnsmasq failed : {e}")))?; Ok(()) } } #[cfg(test)] mod test { use crate::config::DummyOPNSenseShell; use super::*; use opnsense_config_xml::OPNsense; use std::net::Ipv4Addr; use std::sync::Arc; /// Helper function to create a DnsmasqHost with minimal boilerplate. fn create_host(uuid: &str, host: &str, ip: &str, hwaddr: &str) -> DnsmasqHost { DnsmasqHost { uuid: uuid.to_string(), host: host.to_string(), ip: ip.into(), hwaddr: hwaddr.into(), local: MaybeString::from("1"), ignore: Some(0), ..Default::default() } } /// Helper to set up the test environment with an initial OPNsense configuration. fn setup_test_env(initial_hosts: Vec) -> DhcpConfigDnsMasq<'static> { let opnsense_config = Box::leak(Box::new(OPNsense { dnsmasq: Some(DnsMasq { hosts: initial_hosts, ..Default::default() }), ..Default::default() })); DhcpConfigDnsMasq::new(opnsense_config, Arc::new(DummyOPNSenseShell {})) } #[test] fn test_add_first_static_mapping() { let mut dhcp_config = setup_test_env(vec![]); let ip = Ipv4Addr::new(192, 168, 1, 10); let mac = "00:11:22:33:44:55"; let hostname = "new-host"; dhcp_config.add_static_mapping(mac, ip, hostname).unwrap(); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert_eq!(hosts.len(), 1); let host = &hosts[0]; assert_eq!(host.host, hostname); assert_eq!(host.ip, ip.to_string().into()); assert_eq!(host.hwaddr.content_string(), mac); assert!(Uuid::parse_str(&host.uuid).is_ok()); } #[test] fn test_add_mac_to_existing_host_by_ip_and_hostname() { let initial_host = create_host( "uuid-1", "existing-host", "192.168.1.20", "AA:BB:CC:DD:EE:FF", ); let mut dhcp_config = setup_test_env(vec![initial_host]); let ip = Ipv4Addr::new(192, 168, 1, 20); let new_mac = "00:11:22:33:44:55"; let hostname = "existing-host"; dhcp_config .add_static_mapping(new_mac, ip, hostname) .unwrap(); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert_eq!(hosts.len(), 1); let host = &hosts[0]; assert_eq!( host.hwaddr.content_string(), "AA:BB:CC:DD:EE:FF,00:11:22:33:44:55" ); } #[test] fn test_add_mac_to_existing_host_by_ip_only() { let initial_host = create_host( "uuid-1", "existing-host", "192.168.1.20", "AA:BB:CC:DD:EE:FF", ); let mut dhcp_config = setup_test_env(vec![initial_host]); let ip = Ipv4Addr::new(192, 168, 1, 20); let new_mac = "00:11:22:33:44:55"; // Using a different hostname should still find the host by IP and log a warning. dhcp_config .add_static_mapping(new_mac, ip, "different-host-name") .unwrap(); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert_eq!(hosts.len(), 1); let host = &hosts[0]; assert_eq!( host.hwaddr.content_string(), "AA:BB:CC:DD:EE:FF,00:11:22:33:44:55" ); assert_eq!(host.host, "existing-host"); // Original hostname should be preserved. } #[test] fn test_add_mac_to_existing_host_by_hostname_only() { let initial_host = create_host( "uuid-1", "existing-host", "192.168.1.20", "AA:BB:CC:DD:EE:FF", ); let mut dhcp_config = setup_test_env(vec![initial_host]); let new_mac = "00:11:22:33:44:55"; let hostname = "existing-host"; // Using a different IP should still find the host by hostname and log a warning. dhcp_config .add_static_mapping(new_mac, Ipv4Addr::new(192, 168, 1, 99), hostname) .unwrap(); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert_eq!(hosts.len(), 1); let host = &hosts[0]; assert_eq!( host.hwaddr.content_string(), "AA:BB:CC:DD:EE:FF,00:11:22:33:44:55" ); assert_eq!(host.ip.content_string(), "192.168.1.20"); // Original IP should be preserved. } #[test] fn test_add_duplicate_mac_to_host() { let initial_mac = "AA:BB:CC:DD:EE:FF"; let initial_host = create_host("uuid-1", "host-1", "192.168.1.20", initial_mac); let mut dhcp_config = setup_test_env(vec![initial_host]); dhcp_config .add_static_mapping(initial_mac, Ipv4Addr::new(192, 168, 1, 20), "host-1") .unwrap(); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert_eq!(hosts.len(), 1); assert_eq!(hosts[0].hwaddr.content_string(), initial_mac); // No change, no duplication. } #[test] fn test_add_invalid_mac_address() { let mut dhcp_config = setup_test_env(vec![]); let result = dhcp_config.add_static_mapping("invalid-mac", Ipv4Addr::new(10, 0, 0, 1), "host"); assert!(matches!(result, Err(DhcpError::InvalidMacAddress(_)))); } #[test] #[should_panic( expected = "Configuration conflict: IP 192.168.1.10 and hostname 'host-b' exist, but in different static host entries." )] fn test_panic_on_conflicting_ip_and_hostname() { let host_a = create_host("uuid-a", "host-a", "192.168.1.10", "AA:AA:AA:AA:AA:AA"); let host_b = create_host("uuid-b", "host-b", "192.168.1.20", "BB:BB:BB:BB:BB:BB"); let mut dhcp_config = setup_test_env(vec![host_a, host_b]); // This IP belongs to host-a, but the hostname belongs to host-b. dhcp_config .add_static_mapping( "CC:CC:CC:CC:CC:CC", Ipv4Addr::new(192, 168, 1, 10), "host-b", ) .unwrap(); } #[test] #[should_panic( expected = "Configuration conflict: Found multiple host entries matching IP 192.168.1.30 and/or hostname 'new-host'." )] fn test_panic_on_multiple_ip_matches() { let host_a = create_host("uuid-a", "host-a", "192.168.1.30", "AA:AA:AA:AA:AA:AA"); let host_b = create_host("uuid-b", "host-b", "192.168.1.30", "BB:BB:BB:BB:BB:BB"); let mut dhcp_config = setup_test_env(vec![host_a, host_b]); // This IP is ambiguous. dhcp_config .add_static_mapping( "CC:CC:CC:CC:CC:CC", Ipv4Addr::new(192, 168, 1, 30), "new-host", ) .unwrap(); } #[test] fn test_remove_mac_from_multi_mac_host() { let host = create_host("uuid-1", "host-1", "192.168.1.50", "mac-1,mac-2,mac-3"); let mut dhcp_config = setup_test_env(vec![host]); dhcp_config.remove_static_mapping("mac-2"); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert_eq!(hosts.len(), 1); assert_eq!(hosts[0].hwaddr.content_string(), "mac-1,mac-3"); } #[test] fn test_remove_last_mac_from_host() { let host = create_host("uuid-1", "host-1", "192.168.1.50", "mac-1"); let mut dhcp_config = setup_test_env(vec![host]); dhcp_config.remove_static_mapping("mac-1"); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert!(hosts.is_empty()); } #[test] fn test_remove_non_existent_mac() { let host = create_host("uuid-1", "host-1", "192.168.1.50", "mac-1,mac-2"); let mut dhcp_config = setup_test_env(vec![host.clone()]); dhcp_config.remove_static_mapping("mac-nonexistent"); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert_eq!(hosts.len(), 1); assert_eq!(hosts[0], host); // The host should be unchanged. } #[test] fn test_remove_mac_case_insensitively() { let host = create_host("uuid-1", "host-1", "192.168.1.50", "AA:BB:CC:DD:EE:FF"); let mut dhcp_config = setup_test_env(vec![host]); dhcp_config.remove_static_mapping("aa:bb:cc:dd:ee:ff"); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert!(hosts.is_empty()); } #[test] fn test_remove_mac_from_correct_host_only() { let host1 = create_host( "uuid-1", "host-1", "192.168.1.50", "AA:AA:AA:AA:AA:AA,BB:BB:BB:BB:BB:BB", ); let host2 = create_host( "uuid-2", "host-2", "192.168.1.51", "CC:CC:CC:CC:CC:CC,DD:DD:DD:DD:DD:DD", ); let mut dhcp_config = setup_test_env(vec![host1.clone(), host2.clone()]); dhcp_config.remove_static_mapping("AA:AA:AA:AA:AA:AA"); let hosts = &dhcp_config.opnsense.dnsmasq.as_ref().unwrap().hosts; assert_eq!(hosts.len(), 2); let updated_host1 = hosts.iter().find(|h| h.uuid == "uuid-1").unwrap(); let unchanged_host2 = hosts.iter().find(|h| h.uuid == "uuid-2").unwrap(); assert_eq!(updated_host1.hwaddr.content_string(), "BB:BB:BB:BB:BB:BB"); assert_eq!( unchanged_host2.hwaddr.content_string(), "CC:CC:CC:CC:CC:CC,DD:DD:DD:DD:DD:DD" ); } }