Compare commits
2 Commits
feat/agent
...
feat/opnse
| Author | SHA1 | Date | |
|---|---|---|---|
| 904d316605 | |||
| 3b59cb605d |
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user