Compare commits

...

2 Commits

Author SHA1 Message Date
904d316605 feat(dns): make DnsServer::remove_record async and implement for OPNsense
The trait method was sync, which prevented the OPNsense adapter from
calling the async dnsmasq API. Make the method async across the trait
and all implementations, and wire the OPNsense impl to
dhcp().remove_dns_host().
2026-04-10 07:11:26 -04:00
3b59cb605d feat(opnsense): implement DnsServer trait via dnsmasq API
Replaces the four todo!() stubs in infra/opnsense/dns.rs with
implementations backed by the typed dnsmasq API in opnsense-config.

- register_hosts: iterates DnsRecord list, creates host overrides
- list_records: returns A/AAAA records from dnsmasq host overrides
- register_dhcp_leases: toggles regdhcp/regdhcpstatic flags
- remove_record: returns an error (trait method is sync, dnsmasq API
  is async — follow-up needed to make the trait method async)

Adds DnsHostEntry, list_dns_hosts, add_dns_host (idempotent —
updates IP if hostname+domain exists), remove_dns_host, and
set_register_dhcp_leases to opnsense-config dnsmasq module.
2026-04-10 07:08:53 -04:00
4 changed files with 232 additions and 16 deletions

View File

@@ -161,8 +161,12 @@ impl DnsServer for HAClusterTopology {
async fn register_hosts(&self, hosts: Vec<DnsRecord>) -> Result<(), ExecutorError> {
self.dns_server.register_hosts(hosts).await
}
fn remove_record(&self, name: &str, record_type: DnsRecordType) -> Result<(), ExecutorError> {
self.dns_server.remove_record(name, record_type)
async fn remove_record(
&self,
name: &str,
record_type: DnsRecordType,
) -> Result<(), ExecutorError> {
self.dns_server.remove_record(name, record_type).await
}
async fn list_records(&self) -> Vec<DnsRecord> {
self.dns_server.list_records().await
@@ -548,7 +552,11 @@ impl DnsServer for DummyInfra {
async fn register_hosts(&self, _hosts: Vec<DnsRecord>) -> Result<(), ExecutorError> {
unimplemented!("{}", UNIMPLEMENTED_DUMMY_INFRA)
}
fn remove_record(&self, _name: &str, _record_type: DnsRecordType) -> Result<(), ExecutorError> {
async fn remove_record(
&self,
_name: &str,
_record_type: DnsRecordType,
) -> Result<(), ExecutorError> {
unimplemented!("{}", UNIMPLEMENTED_DUMMY_INFRA)
}
async fn list_records(&self) -> Vec<DnsRecord> {

View File

@@ -90,7 +90,11 @@ pub trait DhcpServer: Send + Sync + Debug {
pub trait DnsServer: Send + Sync {
async fn register_dhcp_leases(&self, register: bool) -> Result<(), ExecutorError>;
async fn register_hosts(&self, hosts: Vec<DnsRecord>) -> Result<(), ExecutorError>;
fn remove_record(&self, name: &str, record_type: DnsRecordType) -> Result<(), ExecutorError>;
async fn remove_record(
&self,
name: &str,
record_type: DnsRecordType,
) -> Result<(), ExecutorError>;
async fn list_records(&self) -> Vec<DnsRecord>;
fn get_ip(&self) -> IpAddress;
fn get_host(&self) -> LogicalHost;
@@ -390,7 +394,7 @@ mod test {
Ok(())
}
fn remove_record(
async fn remove_record(
&self,
_name: &str,
_record_type: DnsRecordType,

View File

@@ -1,29 +1,96 @@
use crate::infra::opnsense::LogicalHost;
use crate::{
executors::ExecutorError,
topology::{DnsRecord, DnsServer},
topology::{DnsRecord, DnsRecordType, DnsServer},
};
use async_trait::async_trait;
use harmony_types::net::IpAddress;
use log::{info, warn};
use super::OPNSenseFirewall;
#[async_trait]
impl DnsServer for OPNSenseFirewall {
async fn register_hosts(&self, _hosts: Vec<DnsRecord>) -> Result<(), ExecutorError> {
todo!("Refactor this to use dnsmasq API")
async fn register_hosts(&self, hosts: Vec<DnsRecord>) -> Result<(), ExecutorError> {
let dhcp = self.opnsense_config.dhcp();
for record in &hosts {
info!(
"Registering DNS host override: {}.{} -> {}",
record.host, record.domain, record.value
);
dhcp.add_dns_host(&record.host, &record.domain, &record.value.to_string())
.await
.map_err(|e| {
ExecutorError::UnexpectedError(format!(
"Failed to register DNS host {}.{}: {e}",
record.host, record.domain
))
})?;
}
Ok(())
}
fn remove_record(
async fn remove_record(
&self,
_name: &str,
_record_type: crate::topology::DnsRecordType,
name: &str,
_record_type: DnsRecordType,
) -> Result<(), ExecutorError> {
todo!()
let (hostname, domain) = name.split_once('.').ok_or_else(|| {
ExecutorError::UnexpectedError(format!(
"DNS record name '{name}' must be a fully qualified name (host.domain)"
))
})?;
info!("Removing DNS host override: {hostname}.{domain}");
self.opnsense_config
.dhcp()
.remove_dns_host(hostname, domain)
.await
.map_err(|e| {
ExecutorError::UnexpectedError(format!(
"Failed to remove DNS host {hostname}.{domain}: {e}"
))
})
}
async fn list_records(&self) -> Vec<crate::topology::DnsRecord> {
todo!("Refactor this to use dnsmasq API")
async fn list_records(&self) -> Vec<DnsRecord> {
match self.opnsense_config.dhcp().list_dns_hosts().await {
Ok(entries) => entries
.into_iter()
.filter_map(|entry| {
let ip: IpAddress = match entry.ip.parse() {
Ok(ip) => ip,
Err(e) => {
warn!(
"Skipping DNS host {}.{} with unparseable IP '{}': {e}",
entry.host, entry.domain, entry.ip
);
return None;
}
};
// Dnsmasq host overrides are A records (IPv4) or AAAA (IPv6)
let record_type = if ip.is_ipv4() {
DnsRecordType::A
} else {
DnsRecordType::AAAA
};
Some(DnsRecord {
host: entry.host,
domain: entry.domain,
record_type,
value: ip,
})
})
.collect(),
Err(e) => {
warn!("Failed to list DNS records: {e}");
vec![]
}
}
}
fn get_ip(&self) -> IpAddress {
@@ -34,8 +101,15 @@ impl DnsServer for OPNSenseFirewall {
self.host.clone()
}
async fn register_dhcp_leases(&self, _register: bool) -> Result<(), ExecutorError> {
todo!("Refactor this to use dnsmasq API")
async fn register_dhcp_leases(&self, register: bool) -> Result<(), ExecutorError> {
info!("Setting register DHCP leases as DNS: {register}");
self.opnsense_config
.dhcp()
.set_register_dhcp_leases(register)
.await
.map_err(|e| {
ExecutorError::UnexpectedError(format!("Failed to set register DHCP leases: {e}"))
})
}
async fn commit_config(&self) -> Result<(), ExecutorError> {

View File

@@ -77,6 +77,14 @@ fn extract_selected_key(value: &serde_json::Value) -> Option<String> {
}
}
/// A DNS host override entry returned by [`DhcpConfigDnsMasq::list_dns_hosts`].
pub struct DnsHostEntry {
pub uuid: String,
pub host: String,
pub domain: String,
pub ip: String,
}
impl DhcpConfigDnsMasq {
pub fn new(client: OpnsenseClient, shell: Arc<dyn OPNsenseShell>) -> Self {
Self { client, shell }
@@ -443,6 +451,128 @@ dhcp-boot=tag:bios,tag:!ipxe,{bios_filename}{tftp_str}
Ok(result)
}
// ── DNS host override methods ────────────────────────────────────────
/// Lists all DNS host override entries (hostname, domain, IP).
///
/// Entries with missing hostname or IP are silently skipped.
pub async fn list_dns_hosts(&self) -> Result<Vec<DnsHostEntry>, Error> {
let settings = self.get_settings().await?;
let mut result = Vec::new();
for (uuid, entry) in &settings.dnsmasq.hosts {
let Some(host) = entry.host.as_deref().filter(|s| !s.is_empty()) else {
continue;
};
let Some(ip) = entry.ip.as_deref().filter(|s| !s.is_empty()) else {
continue;
};
let domain = entry.domain.as_deref().unwrap_or("").to_string();
result.push(DnsHostEntry {
uuid: uuid.clone(),
host: host.to_string(),
domain,
ip: ip.to_string(),
});
}
Ok(result)
}
/// Adds a DNS host override entry for the given hostname, domain, and IP.
///
/// If an entry with the same hostname and domain already exists, its IP is
/// updated instead of creating a duplicate.
pub async fn add_dns_host(&self, hostname: &str, domain: &str, ip: &str) -> Result<(), Error> {
let settings = self.get_settings().await?;
// Check for existing entry with same hostname + domain
let existing = settings.dnsmasq.hosts.iter().find(|(_, h)| {
h.host.as_deref() == Some(hostname) && h.domain.as_deref() == Some(domain)
});
let host = DnsmasqHost {
host: Some(hostname.to_string()),
domain: Some(domain.to_string()),
ip: Some(vec![ip.to_string()]),
local: Some(true),
..Default::default()
};
if let Some((uuid, _)) = existing {
info!("Updating DNS host override {uuid}: {hostname}.{domain} -> {ip}");
self.api().set_host(uuid, &host).await.map_err(Error::Api)?;
} else {
info!("Creating DNS host override: {hostname}.{domain} -> {ip}");
self.api().add_host(&host).await.map_err(Error::Api)?;
}
self.client
.reconfigure("dnsmasq")
.await
.map_err(Error::Api)?;
Ok(())
}
/// Removes a DNS host override by hostname and domain.
///
/// Returns `Ok(())` even if no matching entry exists (idempotent).
pub async fn remove_dns_host(&self, hostname: &str, domain: &str) -> Result<(), Error> {
let settings = self.get_settings().await?;
let matching: Vec<String> = settings
.dnsmasq
.hosts
.iter()
.filter(|(_, h)| {
h.host.as_deref() == Some(hostname) && h.domain.as_deref() == Some(domain)
})
.map(|(uuid, _)| uuid.clone())
.collect();
for uuid in &matching {
info!("Deleting DNS host override {uuid}: {hostname}.{domain}");
self.api().del_host(uuid).await.map_err(Error::Api)?;
}
if !matching.is_empty() {
self.client
.reconfigure("dnsmasq")
.await
.map_err(Error::Api)?;
}
Ok(())
}
/// Enable or disable registering DHCP leases as DNS entries.
///
/// Sets both `regdhcp` (dynamic leases) and `regdhcpstatic` (static mappings).
pub async fn set_register_dhcp_leases(&self, register: bool) -> Result<(), Error> {
let settings = opnsense_api::generated::dnsmasq::Dnsmasq {
regdhcp: Some(register),
regdhcpstatic: Some(register),
..Default::default()
};
// The OPNsense API expects the top-level settings wrapped in {"dnsmasq": {...}}
let envelope = serde_json::json!({ "dnsmasq": settings });
let _: serde_json::Value = self
.client
.post_typed("dnsmasq", "settings", "set", Some(&envelope))
.await
.map_err(Error::Api)?;
info!("Set register DHCP leases as DNS: regdhcp={register}, regdhcpstatic={register}");
self.client
.reconfigure("dnsmasq")
.await
.map_err(Error::Api)?;
Ok(())
}
fn is_valid_mac(mac: &str) -> bool {
let parts: Vec<&str> = mac.split(':').collect();
if parts.len() != 6 {