From 2eaa5c215d80a9700a89e0a259afb1bfbda9ca86 Mon Sep 17 00:00:00 2001 From: Luna Yao <40349250+ZnqbuZ@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:39:14 +0100 Subject: [PATCH] add zone module with Zone struct and related functionality --- easytier/src/dns/mod.rs | 1 + easytier/src/dns/zone.rs | 270 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 easytier/src/dns/zone.rs diff --git a/easytier/src/dns/mod.rs b/easytier/src/dns/mod.rs index 3a9134a6..e7e66cd6 100644 --- a/easytier/src/dns/mod.rs +++ b/easytier/src/dns/mod.rs @@ -1,2 +1,3 @@ pub mod config; mod utils; +pub mod zone; diff --git a/easytier/src/dns/zone.rs b/easytier/src/dns/zone.rs new file mode 100644 index 00000000..5246ba96 --- /dev/null +++ b/easytier/src/dns/zone.rs @@ -0,0 +1,270 @@ +use crate::dns::utils::NameServerAddr; +use crate::proto::dns::ZoneConfigPb; +use hickory_proto::rr::{LowerName, Record, RecordSet, RrKey, RrsetRecords}; +use hickory_proto::serialize::txt::Parser; +use hickory_proto::xfer::Protocol; +use hickory_resolver::config::{NameServerConfig, ResolverOpts}; +use hickory_resolver::name_server::TokioConnectionProvider; +use hickory_server::authority::{AuthorityObject, ZoneType}; +use hickory_server::store::forwarder::{ForwardAuthority, ForwardConfig}; +use hickory_server::store::in_memory::InMemoryAuthority; +use std::collections::BTreeMap; +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::sync::Arc; +use url::Url; + +#[derive(Debug, Clone)] +pub struct Zone { + pub(crate) origin: LowerName, + pub(crate) records: BTreeMap, + pub(crate) forward: Option, +} + +impl Zone { + pub fn new(name: LowerName) -> Self { + Self { + origin: name, + records: BTreeMap::new(), + forward: None, + } + } + + pub fn create_authorities(&self) -> anyhow::Result>> { + let mut authorities = Vec::>::with_capacity(2); + + let memory = InMemoryAuthority::new( + self.origin.clone().into(), + self.records.clone(), + ZoneType::Primary, + false, + ) + .map_err(|e| anyhow::anyhow!("failed to create memory authority: {}", e))?; + authorities.push(Arc::new(memory)); + + if let Some(forward) = &self.forward { + let forward = ForwardAuthority::builder_with_config( + forward.clone(), + TokioConnectionProvider::default(), + ) + .build() + .map_err(|e| anyhow::anyhow!("failed to create forward authority: {}", e))?; + authorities.push(Arc::new(forward)); + } + + Ok(authorities) + } + + fn parse_name_server(addr: A) -> anyhow::Result + where + A: AsRef, + { + const SUPPORTED_PROTOCOLS: [Protocol; 2] = [ + Protocol::Udp, + Protocol::Tcp, + // Protocol::Tls, + // Protocol::Https, + // Protocol::Quic, + // Protocol::H3, + ]; + + let addr = addr.as_ref(); + + let url = if addr.parse::().is_ok() || addr.parse::().is_ok() { + Url::parse(&format!("udp://{addr}"))? + } else { + Url::parse(addr)? + }; + + let scheme = url.scheme(); + let protocol = *SUPPORTED_PROTOCOLS + .iter() + .find(|p| p.to_string().to_lowercase() == scheme) + .ok_or(anyhow::anyhow!("unsupported scheme: {scheme}"))?; + let addr = url.host_str().ok_or(anyhow::anyhow!("host not found"))?; + let addr = addr + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .map_err(|e| anyhow::anyhow!("invalid ip address '{addr}': {e}"))?; + let port = if let Some(port) = url.port() { + port + } else { + match protocol { + Protocol::Udp | Protocol::Tcp => 53, + _ => return Err(anyhow::anyhow!("port not found")), + } + }; + let addr = SocketAddr::new(addr, port); + Ok(NameServerConfig::new(addr, protocol)) + } + + pub fn set_forwarders( + &mut self, + forwarders: F, + options: Option, + ) -> anyhow::Result<()> + where + F: Iterator, + A: AsRef, + { + let name_servers = forwarders + .into_iter() + .map(|s| s.as_ref().parse::().map(Into::into)) + .collect::>>()? + .into(); + self.forward = Some(ForwardConfig { + name_servers, + options, + }); + Ok(()) + } + + pub fn iter_records(&self) -> impl Iterator { + self.records + .values() + .filter(|set| !set.is_empty()) + .flat_map(|set| { + let RrsetRecords::RecordsOnly(records) = set.records_without_rrsigs() else { + unreachable!() + }; + records + }) + } +} + +impl FromStr for Zone { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let (origin, records) = Parser::new(s, None, None) + .parse() + .map_err(|e| anyhow::anyhow!("failed to parse zone file: {e}"))?; + + let mut zone = Zone::new(origin.clone().into()); + zone.records = records; + Ok(zone) + } +} + +impl TryFrom<&ZoneConfigPb> for Zone { + type Error = anyhow::Error; + + fn try_from(value: &ZoneConfigPb) -> Result { + let mut zone: Zone = value.to_string().parse()?; + zone.set_forwarders(value.forwarders.iter(), None)?; + + Ok(zone) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dns::config::DnsConfig; + use hickory_proto::rr::{rdata, Name, RData, RecordType}; + + #[tokio::test] + async fn config() -> anyhow::Result<()> { + let sep = "=".repeat(80); + let text = r#" + listeners = [ + "1.1.1.1", + ] + + name = "et-test" + domain = "测试.net" + + [[zone]] + origin = "et.internal" + + broadcast = true + + ttl = 10 + + records = [ + "www 0 IN A 123.123.123.123", + "app IN CNAME www", + "ftp IN AAAA ::", + "mail IN MX 10 app", + ] + + forwarders = [ + "1.1.1.1", + ] + + [[zone]] + origin = "et.top" + + records = [ + "@ 60 A 100.100.100.100", + ] + + "#; + + let config = toml::from_str::(text)?; + assert_eq!(config.domain.to_string(), "测试.net"); + let mut zones = config.zones; + assert_eq!(zones.len(), 2); + + let zone = zones + .extract_if(.., |z| z.origin.to_string() == "et.internal") + .next() + .unwrap(); + assert_eq!(zone.broadcast, true); + let zone = ZoneConfigPb::from(&zone); + println!("{}", sep); + println!("{}", zone); + println!("{}", sep); + let zone = Zone::try_from(&zone)?; + assert_eq!(zone.origin.to_string(), "et.internal."); + let records = zone.iter_records().collect::>(); + assert_eq!(records.len(), 4); + + let mut record = Record::update0( + Name::from_str("www")?.append_domain(&*zone.origin)?, + 60, + RecordType::A, + ); + record.set_data(RData::A(rdata::a::A("123.123.123.123".parse()?))); + assert_eq!( + record, + **records + .iter() + .find(|r| r.name().to_string().starts_with("www.")) + .unwrap() + ); + + let mut record = Record::update0( + Name::from_str("app")?.append_domain(&*zone.origin)?, + 10, + RecordType::CNAME, + ); + record.set_data(RData::CNAME(rdata::name::CNAME( + Name::from_str("www")?.append_domain(&*zone.origin)?, + ))); + assert_eq!( + record, + **records + .iter() + .find(|r| r.name().to_string().starts_with("app.")) + .unwrap() + ); + + let zone = zones + .extract_if(.., |c| c.origin.to_string() == "et.top") + .next() + .unwrap(); + let zone = ZoneConfigPb::from(&zone); + let zone = Zone::try_from(&zone)?; + assert_eq!(zone.origin.to_string(), "et.top."); + let records = zone.iter_records().collect::>(); + assert_eq!(records.len(), 1); + + let mut record = Record::update0(zone.origin.clone().into(), 60, RecordType::A); + record.set_data(RData::A(rdata::a::A("100.100.100.100".parse()?))); + assert_eq!(record, **records.iter().next().unwrap()); + + Ok(()) + } +}