// dnsmasq.rs use crate::modules::dhcp::DhcpError; use log::{debug, info}; use opnsense_config_xml::dnsmasq::{DhcpBoot, DhcpOptions, DnsMasq}; use opnsense_config_xml::{MaybeString, StaticMap}; 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 static mapping by its MAC address. /// Static mappings are stored in the section of the config, shared with the ISC module. 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); } /// Retrieves a mutable reference to the LAN interface's DHCP configuration. /// This is located in the shared section of the config. fn get_lan_dhcpd(&mut self) -> &mut opnsense_config_xml::DhcpInterface { &mut self .opnsense .dhcpd .elements .iter_mut() .find(|(name, _config)| name == "lan") .expect("Interface lan should have dhcpd activated") .1 } fn dnsmasq(&mut self) -> &mut DnsMasq { self.opnsense .dnsmasq .as_mut() .expect("Dnsmasq config should exist. Maybe it is not installed yet") } /// Adds a new static DHCP mapping. /// Validates the MAC address and checks for existing mappings to prevent conflicts. 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: Validate that the IP address is within a configured DHCP range. if existing_mappings .iter() .any(|m| m.ipaddr == ipaddr.to_string() && m.mac == mac) { info!("Mapping already exists for {} [{}], skipping", ipaddr, mac); return Ok(()); } if existing_mappings .iter() .any(|m| m.ipaddr == ipaddr.to_string()) { 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: hostname, ..Default::default() }; existing_mappings.push(static_map); 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> { 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}")); 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_pxe_options( &self, tftp_ip: Option, bios_filename: String, efi_filename: String, ipxe_filename: String, ) -> Result<(), DhcpError> { // As of writing this opnsense does not support negative tags, and the dnsmasq config is a // bit complicated anyways. So we are writing directly a dnsmasq config file to // /usr/local/etc/dnsmasq.conf.d 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(()) } }