use std::net::IpAddr; use anyhow::{Context, Result}; use hickory_resolver::config::{NameServerConfigGroup, ResolverConfig}; use hickory_resolver::name_server::TokioConnectionProvider; use hickory_resolver::TokioResolver; pub struct Checker { expected_ns: Vec, nameservers: Option>, dns_port: u16, } pub struct CheckResult { pub verified: bool, pub ns_records: Vec, pub txt_match: bool, } impl Checker { pub fn new(expected_ns: Vec, nameservers: Option>, dns_port: u16) -> Self { let expected_ns = expected_ns .into_iter() .map(|ns| ns.to_lowercase().trim_end_matches('.').to_string()) .collect(); Self { expected_ns, nameservers, dns_port, } } fn build_resolver(&self) -> Result { let resolver = match &self.nameservers { Some(addrs) => { let ns_group = NameServerConfigGroup::from_ips_clear(addrs, self.dns_port, true); let config = ResolverConfig::from_parts(None, vec![], ns_group); TokioResolver::builder_with_config(config, TokioConnectionProvider::default()) .build() } None => TokioResolver::builder_tokio() .context("failed to read system resolver config")? .build(), }; Ok(resolver) } pub async fn check_zone(&self, zone: &str, did: &str) -> Result { let resolver = self.build_resolver()?; let fqdn = if zone.ends_with('.') { zone.to_string() } else { format!("{zone}.") }; let ns_lookup = resolver .ns_lookup(&fqdn) .await .context("NS lookup failed")?; let ns_records: Vec = ns_lookup .iter() .map(|ns| ns.to_string().to_lowercase().trim_end_matches('.').to_string()) .collect(); tracing::debug!( zone = %zone, ns = ?ns_records, "NS lookup result" ); let ns_valid = self.expected_ns.iter().all(|expected| { ns_records.iter().any(|actual| actual == expected) }); let txt_name = format!("_onis-verify.{fqdn}"); let txt_match = match resolver.txt_lookup(&txt_name).await { Ok(txt_lookup) => txt_lookup.iter().any(|txt| { let value: String = txt .txt_data() .iter() .map(|d| String::from_utf8_lossy(d)) .collect(); value.trim() == did }), Err(e) => { tracing::debug!( zone = %zone, name = %txt_name, error = %e, "TXT ownership lookup failed or no record found" ); false } }; tracing::debug!( zone = %zone, ns_valid, txt_match, did = %did, "verification check result" ); let verified = ns_valid && txt_match; Ok(CheckResult { verified, ns_records, txt_match, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn new_normalizes_trailing_dots() { let checker = Checker::new( vec![ "ns1.example.com.".to_string(), "ns2.example.com.".to_string(), ], None, 53, ); assert_eq!(checker.expected_ns, vec!["ns1.example.com", "ns2.example.com"]); } #[test] fn new_normalizes_to_lowercase() { let checker = Checker::new(vec!["NS1.Example.COM".to_string()], None, 53); assert_eq!(checker.expected_ns, vec!["ns1.example.com"]); } #[test] fn new_normalizes_case_and_dots() { let checker = Checker::new(vec!["NS1.Example.COM.".to_string()], None, 53); assert_eq!(checker.expected_ns, vec!["ns1.example.com"]); } #[test] fn new_empty_expected_ns() { let checker = Checker::new(vec![], None, 53); assert!(checker.expected_ns.is_empty()); } #[test] fn new_preserves_dns_port() { let checker = Checker::new(vec![], None, 5353); assert_eq!(checker.dns_port, 5353); } }