feat(opnsense-config): dnsmasq dhcp static mappings (#130)
All checks were successful
Run Check Script / check (pull_request) Successful in 59s
All checks were successful
Run Check Script / check (pull_request) Successful in 59s
Co-authored-by: Jean-Gabriel Gill-Couture <jeangabriel.gc@gmail.com> Co-authored-by: Ian Letourneau <ian@noma.to> Reviewed-on: #130 Reviewed-by: Ian Letourneau <ian@noma.to> Co-authored-by: Jean-Gabriel Gill-Couture <jg@nationtech.io> Co-committed-by: Jean-Gabriel Gill-Couture <jg@nationtech.io>
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
config::{SshConfigManager, SshCredentials, SshOPNSenseShell},
|
||||
config::{check_hash, get_hash, SshConfigManager, SshCredentials, SshOPNSenseShell},
|
||||
error::Error,
|
||||
modules::{
|
||||
caddy::CaddyConfig, dhcp_legacy::DhcpConfigLegacyISC, dns::DnsConfig,
|
||||
caddy::CaddyConfig, dhcp_legacy::DhcpConfigLegacyISC, dns::UnboundDnsConfig,
|
||||
dnsmasq::DhcpConfigDnsMasq, load_balancer::LoadBalancerConfig, tftp::TftpConfig,
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,7 @@ use log::{debug, info, trace, warn};
|
||||
use opnsense_config_xml::OPNsense;
|
||||
use russh::client;
|
||||
use serde::Serialize;
|
||||
use sha2::Digest;
|
||||
|
||||
use super::{ConfigManager, OPNsenseShell};
|
||||
|
||||
@@ -20,6 +21,7 @@ pub struct Config {
|
||||
opnsense: OPNsense,
|
||||
repository: Arc<dyn ConfigManager>,
|
||||
shell: Arc<dyn OPNsenseShell>,
|
||||
hash: String,
|
||||
}
|
||||
|
||||
impl Serialize for Config {
|
||||
@@ -36,8 +38,10 @@ impl Config {
|
||||
repository: Arc<dyn ConfigManager>,
|
||||
shell: Arc<dyn OPNsenseShell>,
|
||||
) -> Result<Self, Error> {
|
||||
let (opnsense, hash) = Self::get_opnsense_instance(repository.clone()).await?;
|
||||
Ok(Self {
|
||||
opnsense: Self::get_opnsense_instance(repository.clone()).await?,
|
||||
opnsense,
|
||||
hash,
|
||||
repository,
|
||||
shell,
|
||||
})
|
||||
@@ -51,8 +55,8 @@ impl Config {
|
||||
DhcpConfigDnsMasq::new(&mut self.opnsense, self.shell.clone())
|
||||
}
|
||||
|
||||
pub fn dns(&mut self) -> DnsConfig<'_> {
|
||||
DnsConfig::new(&mut self.opnsense)
|
||||
pub fn dns(&mut self) -> DhcpConfigDnsMasq<'_> {
|
||||
DhcpConfigDnsMasq::new(&mut self.opnsense, self.shell.clone())
|
||||
}
|
||||
|
||||
pub fn tftp(&mut self) -> TftpConfig<'_> {
|
||||
@@ -146,7 +150,7 @@ impl Config {
|
||||
|
||||
async fn reload_config(&mut self) -> Result<(), Error> {
|
||||
info!("Reloading opnsense live config");
|
||||
self.opnsense = Self::get_opnsense_instance(self.repository.clone()).await?;
|
||||
let (opnsense, sha2) = Self::get_opnsense_instance(self.repository.clone()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -158,14 +162,15 @@ impl Config {
|
||||
/// 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
|
||||
let xml = &self.opnsense.to_xml();
|
||||
self.repository.save_config(xml, &self.hash).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())
|
||||
.apply_new_config(&self.opnsense.to_xml(), &self.hash)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -193,11 +198,14 @@ impl Config {
|
||||
Config::new(manager, shell).await.unwrap()
|
||||
}
|
||||
|
||||
async fn get_opnsense_instance(repository: Arc<dyn ConfigManager>) -> Result<OPNsense, Error> {
|
||||
async fn get_opnsense_instance(
|
||||
repository: Arc<dyn ConfigManager>,
|
||||
) -> Result<(OPNsense, String), Error> {
|
||||
let xml = repository.load_as_str().await?;
|
||||
trace!("xml {}", xml);
|
||||
|
||||
Ok(OPNsense::from(xml))
|
||||
let hash = get_hash(&xml);
|
||||
Ok((OPNsense::from(xml), hash))
|
||||
}
|
||||
|
||||
pub async fn run_command(&self, command: &str) -> Result<String, Error> {
|
||||
@@ -219,13 +227,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_load_config_from_local_file() {
|
||||
for path in [
|
||||
"src/tests/data/config-opnsense-25.1.xml",
|
||||
"src/tests/data/config-vm-test.xml",
|
||||
// "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",
|
||||
"src/tests/data/config-full-ncd0.xml",
|
||||
"src/tests/data/config-full-25.7.xml",
|
||||
"src/tests/data/config-full-25.7-dummy-dnsmasq-options.xml",
|
||||
// "src/tests/data/config-full-ncd0.xml",
|
||||
// "src/tests/data/config-full-25.7.xml",
|
||||
// "src/tests/data/config-full-25.7-dummy-dnsmasq-options.xml",
|
||||
"src/tests/data/config-25.7-dnsmasq-static-host.xml",
|
||||
] {
|
||||
let mut test_file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
test_file_path.push(path);
|
||||
@@ -243,13 +252,13 @@ mod tests {
|
||||
|
||||
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
|
||||
[config_file_str.lines().collect::<Vec<_>>()].sort();
|
||||
[config_file_str.lines().collect::<Vec<_>>()].sort();
|
||||
assert_eq!((), ());
|
||||
let mut before = config_file_str.lines().collect::<Vec<_>>();
|
||||
let mut after = serialized.lines().collect::<Vec<_>>();
|
||||
before.sort();
|
||||
after.sort();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,8 +288,6 @@ mod tests {
|
||||
|
||||
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");
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::config::check_hash;
|
||||
use crate::config::manager::ConfigManager;
|
||||
use crate::error::Error;
|
||||
use async_trait::async_trait;
|
||||
@@ -20,11 +21,17 @@ impl ConfigManager for LocalFileConfigManager {
|
||||
Ok(fs::read_to_string(&self.file_path)?)
|
||||
}
|
||||
|
||||
async fn save_config(&self, content: &str) -> Result<(), Error> {
|
||||
async fn save_config(&self, content: &str, hash: &str) -> Result<(), Error> {
|
||||
let current_content = self.load_as_str().await?;
|
||||
if !check_hash(¤t_content, hash) {
|
||||
return Err(Error::Config(format!(
|
||||
"OPNSense config file changed since loading it! Hash when loading : {hash}"
|
||||
)));
|
||||
}
|
||||
Ok(fs::write(&self.file_path, content)?)
|
||||
}
|
||||
|
||||
async fn apply_new_config(&self, content: &str) -> Result<(), Error> {
|
||||
self.save_config(content).await
|
||||
async fn apply_new_config(&self, content: &str, hash: &str) -> Result<(), Error> {
|
||||
self.save_config(content, hash).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::Error;
|
||||
#[async_trait]
|
||||
pub trait ConfigManager: std::fmt::Debug + Send + Sync {
|
||||
async fn load_as_str(&self) -> Result<String, Error>;
|
||||
async fn save_config(&self, content: &str) -> Result<(), Error>;
|
||||
async fn apply_new_config(&self, content: &str) -> Result<(), Error>;
|
||||
/// Save a new version of the config file, making sure that the hash still represents the file
|
||||
/// currently stored in /conf/config.xml
|
||||
async fn save_config(&self, content: &str, hash: &str) -> Result<(), Error>;
|
||||
async fn apply_new_config(&self, content: &str, hash: &str) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::config::{manager::ConfigManager, OPNsenseShell};
|
||||
use crate::error::Error;
|
||||
use async_trait::async_trait;
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
use russh_keys::key::KeyPair;
|
||||
use sha2::Digest;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -35,10 +36,10 @@ impl SshConfigManager {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn move_to_live_config(&self, new_config_path: &str) -> Result<String, Error> {
|
||||
async fn copy_to_live_config(&self, new_config_path: &str) -> Result<String, Error> {
|
||||
info!("Overwriting OPNSense /conf/config.xml with {new_config_path}");
|
||||
self.opnsense_shell
|
||||
.exec(&format!("mv {new_config_path} /conf/config.xml"))
|
||||
.exec(&format!("cp {new_config_path} /conf/config.xml"))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -56,19 +57,41 @@ impl ConfigManager for SshConfigManager {
|
||||
self.opnsense_shell.exec("cat /conf/config.xml").await
|
||||
}
|
||||
|
||||
async fn save_config(&self, content: &str) -> Result<(), Error> {
|
||||
async fn save_config(&self, content: &str, hash: &str) -> Result<(), Error> {
|
||||
let current_content = self.load_as_str().await?;
|
||||
|
||||
if !check_hash(¤t_content, hash) {
|
||||
warn!("OPNSense config file changed since loading it! Hash when loading : {hash}");
|
||||
// return Err(Error::Config(format!(
|
||||
// "OPNSense config file changed since loading it! Hash when loading : {hash}"
|
||||
// )));
|
||||
}
|
||||
|
||||
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.copy_to_live_config(&temp_filename).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_new_config(&self, content: &str) -> Result<(), Error> {
|
||||
self.save_config(content).await?;
|
||||
async fn apply_new_config(&self, content: &str, hash: &str) -> Result<(), Error> {
|
||||
self.save_config(content, &hash).await?;
|
||||
self.reload_all_services().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_hash(content: &str) -> String {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
let hash_bytes = hasher.finalize();
|
||||
let hash_string = format!("{:x}", hash_bytes);
|
||||
info!("Loaded OPNSense config.xml with hash {hash_string:?}");
|
||||
hash_string
|
||||
}
|
||||
|
||||
pub fn check_hash(content: &str, source_hash: &str) -> bool {
|
||||
get_hash(content) == source_hash
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ impl OPNsenseShell for SshOPNSenseShell {
|
||||
|
||||
async fn write_content_to_temp_file(&self, content: &str) -> Result<String, Error> {
|
||||
let temp_filename = format!(
|
||||
"/tmp/opnsense-config-tmp-config_{}",
|
||||
"/conf/harmony/opnsense-config-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum DhcpError {
|
||||
InvalidMacAddress(String),
|
||||
InvalidIpAddress(String),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use opnsense_config_xml::{Host, OPNsense};
|
||||
|
||||
pub struct DnsConfig<'a> {
|
||||
pub struct UnboundDnsConfig<'a> {
|
||||
opnsense: &'a mut OPNsense,
|
||||
}
|
||||
|
||||
impl<'a> DnsConfig<'a> {
|
||||
impl<'a> UnboundDnsConfig<'a> {
|
||||
pub fn new(opnsense: &'a mut OPNsense) -> Self {
|
||||
Self { opnsense }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// dnsmasq.rs
|
||||
use crate::modules::dhcp::DhcpError;
|
||||
use log::{debug, info};
|
||||
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;
|
||||
|
||||
@@ -25,74 +28,167 @@ impl<'a> DhcpConfigDnsMasq<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a static mapping by its MAC address.
|
||||
/// Static mappings are stored in the <dhcpd> 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);
|
||||
/// 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 LAN interface's DHCP configuration.
|
||||
/// This is located in the shared <dhcpd> 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
|
||||
/// Retrieves a mutable reference to the DnsMasq configuration.
|
||||
/// This is located in the <dnsmasq> section of the OPNsense config.
|
||||
fn get_dnsmasq(&mut self) -> &mut DnsMasq {
|
||||
self.opnsense
|
||||
.dnsmasq
|
||||
.as_mut()
|
||||
.expect("Dnsmasq config must be initialized")
|
||||
}
|
||||
|
||||
/// Adds a new static DHCP mapping.
|
||||
/// Validates the MAC address and checks for existing mappings to prevent conflicts.
|
||||
/// 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 error if the IP and hostname exist but point to two different host entries,
|
||||
/// as this represents an unresolvable conflict.
|
||||
/// - It will also error 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,
|
||||
mac: &Vec<String>,
|
||||
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<StaticMap> = &mut lan_dhcpd.staticmaps;
|
||||
let mut hostname_split = hostname.split(".");
|
||||
let hostname = hostname_split.next().expect("hostname cannot be empty");
|
||||
let domain_name = hostname_split.collect::<Vec<&str>>().join(".");
|
||||
|
||||
if !Self::is_valid_mac(&mac) {
|
||||
return Err(DhcpError::InvalidMacAddress(mac));
|
||||
if let Some(m) = mac.iter().find(|m| !Self::is_valid_mac(m)) {
|
||||
return Err(DhcpError::InvalidMacAddress(m.to_string()));
|
||||
}
|
||||
|
||||
// TODO: Validate that the IP address is within a configured DHCP range.
|
||||
let ip_str = ipaddr.to_string();
|
||||
let hosts = &mut self.get_dnsmasq().hosts;
|
||||
|
||||
if existing_mappings
|
||||
let ip_indices: Vec<usize> = hosts
|
||||
.iter()
|
||||
.any(|m| m.ipaddr == ipaddr.to_string() && m.mac == mac)
|
||||
{
|
||||
info!("Mapping already exists for {} [{}], skipping", ipaddr, mac);
|
||||
return Ok(());
|
||||
}
|
||||
.enumerate()
|
||||
.filter(|(_, h)| h.ip.content_string() == ip_str)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
if existing_mappings
|
||||
let hostname_indices: Vec<usize> = hosts
|
||||
.iter()
|
||||
.any(|m| m.ipaddr == ipaddr.to_string())
|
||||
.enumerate()
|
||||
.filter(|(_, h)| h.host == hostname)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
let ip_set: HashSet<usize> = ip_indices.iter().cloned().collect();
|
||||
let hostname_set: HashSet<usize> = hostname_indices.iter().cloned().collect();
|
||||
|
||||
if !ip_indices.is_empty()
|
||||
&& !hostname_indices.is_empty()
|
||||
&& ip_set.intersection(&hostname_set).count() == 0
|
||||
{
|
||||
return Err(DhcpError::IpAddressAlreadyMapped(ipaddr.to_string()));
|
||||
return Err(DhcpError::Configuration(format!(
|
||||
"Configuration conflict: IP {} and hostname '{}' exist, but in different static host entries.",
|
||||
ipaddr, hostname
|
||||
)));
|
||||
}
|
||||
|
||||
if existing_mappings.iter().any(|m| m.mac == mac) {
|
||||
return Err(DhcpError::MacAddressAlreadyMapped(mac));
|
||||
let mut all_indices: Vec<&usize> = ip_set.union(&hostname_set).collect();
|
||||
all_indices.sort();
|
||||
|
||||
let mac_list = mac.join(",");
|
||||
|
||||
match all_indices.len() {
|
||||
0 => {
|
||||
info!(
|
||||
"Creating new static host for {} ({}) with MAC {}",
|
||||
hostname, ipaddr, mac_list
|
||||
);
|
||||
let new_host = DnsmasqHost {
|
||||
uuid: Uuid::new_v4().to_string(),
|
||||
host: hostname.to_string(),
|
||||
ip: ip_str.into(),
|
||||
hwaddr: mac_list.into(),
|
||||
local: MaybeString::from("1"),
|
||||
ignore: Some(0),
|
||||
domain: domain_name.into(),
|
||||
..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 ({}). Setting new IP {ip_str}. Appending MAC {}.",
|
||||
hostname, host_to_modify_ip, mac_list
|
||||
);
|
||||
host_to_modify.ip.content = Some(ip_str);
|
||||
} else if host_to_modify.host != hostname {
|
||||
warn!(
|
||||
"IP {} already exists with a different hostname ('{}'). Setting hostname to {hostname}. Appending MAC {}.",
|
||||
ipaddr, host_to_modify.host, mac_list
|
||||
);
|
||||
host_to_modify.host = hostname.to_string();
|
||||
}
|
||||
|
||||
for single_mac in mac.iter() {
|
||||
if !host_to_modify
|
||||
.hwaddr
|
||||
.content_string()
|
||||
.split(',')
|
||||
.any(|m| m.eq_ignore_ascii_case(single_mac))
|
||||
{
|
||||
info!(
|
||||
"Appending MAC {} to existing static host for {} ({})",
|
||||
single_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(single_mac);
|
||||
host_to_modify.hwaddr.content = updated_macs.into();
|
||||
} else {
|
||||
debug!(
|
||||
"MAC {} already present in static host entry for {} ({}). No changes made.",
|
||||
single_mac, host_to_modify.host, host_to_modify_ip
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(DhcpError::Configuration(format!(
|
||||
"Configuration conflict: Found multiple host entries matching IP {} and/or hostname '{}'. Cannot resolve automatically.",
|
||||
ipaddr, hostname
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let static_map = StaticMap {
|
||||
mac,
|
||||
ipaddr: ipaddr.to_string(),
|
||||
hostname: hostname,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
existing_mappings.push(static_map);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -110,13 +206,20 @@ impl<'a> DhcpConfigDnsMasq<'a> {
|
||||
/// 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<Vec<StaticMap>, 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}"));
|
||||
let value: serde_json::Value = serde_json::from_str(&list_static_output).map_err(|e| {
|
||||
Error::Command(format!(
|
||||
"Got invalid json from configctl {list_static_output} : {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// 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!(
|
||||
@@ -135,6 +238,36 @@ impl<'a> DhcpConfigDnsMasq<'a> {
|
||||
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<DhcpRange>
|
||||
// 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<String>,
|
||||
@@ -142,9 +275,9 @@ impl<'a> DhcpConfigDnsMasq<'a> {
|
||||
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
|
||||
// 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!(
|
||||
@@ -163,7 +296,7 @@ dhcp-boot=tag:efi,tag:!ipxe,{efi_filename}{tftp_str}
|
||||
dhcp-boot=tag:ipxe,{ipxe_filename}{tftp_str}
|
||||
|
||||
# Provide undionly to legacy bios clients
|
||||
dhcp-boot=tag:bios,{bios_filename}{tftp_str}
|
||||
dhcp-boot=tag:bios,tag:!ipxe,{bios_filename}{tftp_str}
|
||||
"
|
||||
);
|
||||
info!("Writing configuration file to {DNS_MASQ_PXE_CONFIG_FILE}");
|
||||
@@ -185,3 +318,302 @@ dhcp-boot=tag:bios,{bios_filename}{tftp_str}
|
||||
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<DnsmasqHost>) -> 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(&vec![mac.to_string()], &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_hostname_split_into_host_domain() {
|
||||
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";
|
||||
let domain = "some.domain";
|
||||
|
||||
dhcp_config
|
||||
.add_static_mapping(&vec![mac.to_string()], &ip, &format!("{hostname}.{domain}"))
|
||||
.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.domain.content_string(), domain);
|
||||
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(&vec![new_mac.to_string()], &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.
|
||||
let new_hostname = "different-host-name";
|
||||
dhcp_config
|
||||
.add_static_mapping(&vec![new_mac.to_string()], &ip, new_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.host, new_hostname); // hostname should be updated
|
||||
}
|
||||
|
||||
#[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(
|
||||
&vec![new_mac.to_string()],
|
||||
&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.99"); // 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(
|
||||
&vec![initial_mac.to_string()],
|
||||
&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(
|
||||
&vec!["invalid-mac".to_string()],
|
||||
&Ipv4Addr::new(10, 0, 0, 1),
|
||||
"host",
|
||||
);
|
||||
assert!(matches!(result, Err(DhcpError::InvalidMacAddress(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_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]);
|
||||
|
||||
let result = dhcp_config.add_static_mapping(
|
||||
&vec!["CC:CC:CC:CC:CC:CC".to_string()],
|
||||
&Ipv4Addr::new(192, 168, 1, 10),
|
||||
"host-b",
|
||||
);
|
||||
// This IP belongs to host-a, but the hostname belongs to host-b.
|
||||
assert_eq!(result, Err(DhcpError::Configuration("Configuration conflict: IP 192.168.1.10 and hostname 'host-b' exist, but in different static host entries.".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_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.
|
||||
let result = dhcp_config.add_static_mapping(
|
||||
&vec!["CC:CC:CC:CC:CC:CC".to_string()],
|
||||
&Ipv4Addr::new(192, 168, 1, 30),
|
||||
"new-host",
|
||||
);
|
||||
assert_eq!(result, Err(DhcpError::Configuration("Configuration conflict: Found multiple host entries matching IP 192.168.1.30 and/or hostname 'new-host'. Cannot resolve automatically.".to_string())));
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1674
opnsense-config/src/tests/data/config-25.7-dnsmasq-static-host.xml
Normal file
1674
opnsense-config/src/tests/data/config-25.7-dnsmasq-static-host.xml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -215,7 +215,6 @@
|
||||
<description>System Administrators</description>
|
||||
<scope>system</scope>
|
||||
<gid>1999</gid>
|
||||
<member>0</member>
|
||||
<member>2000</member>
|
||||
<priv>page-all</priv>
|
||||
</group>
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<description>System Administrators</description>
|
||||
<scope>system</scope>
|
||||
<gid>1999</gid>
|
||||
<member>0</member>
|
||||
<member>2000</member>
|
||||
<priv>page-all</priv>
|
||||
</group>
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<description>System Administrators</description>
|
||||
<scope>system</scope>
|
||||
<gid>1999</gid>
|
||||
<member>0</member>
|
||||
<member>2000</member>
|
||||
<priv>page-all</priv>
|
||||
</group>
|
||||
|
||||
Reference in New Issue
Block a user