use std::error::Error; use std::str::FromStr; use crate::domain; use mockall::automock; use trust_dns_client::client::{Client, SyncClient}; use trust_dns_client::rr::{DNSClass, Name, RData, RecordType}; use trust_dns_client::udp::UdpClientConnection; #[derive(thiserror::Error, Debug)] pub enum NewDNSCheckerError { #[error("invalid resolver address")] InvalidResolverAddress, #[error("invalid target CNAME")] InvalidTargetCNAME, #[error(transparent)] Unexpected(Box), } #[derive(thiserror::Error, Debug)] pub enum CheckDomainError { #[error("invalid domain name")] InvalidDomainName, #[error("target CNAME not set")] TargetCNAMENotSet, #[error("challenge token not set")] ChallengeTokenNotSet, #[error(transparent)] Unexpected(Box), } #[automock] pub trait Checker: std::marker::Send + std::marker::Sync { fn check_domain( &self, domain: &domain::Name, challenge_token: &str, ) -> Result<(), CheckDomainError>; } pub struct DNSChecker { target_cname: Name, client: SyncClient, } pub fn new( target_cname: domain::Name, resolver_addr: &str, ) -> Result { let resolver_addr = resolver_addr .parse() .map_err(|_| NewDNSCheckerError::InvalidResolverAddress)?; let conn = UdpClientConnection::new(resolver_addr) .map_err(|e| NewDNSCheckerError::Unexpected(Box::from(e)))?; let client = SyncClient::new(conn); Ok(DNSChecker { target_cname: target_cname.inner, client, }) } impl Checker for DNSChecker { fn check_domain( &self, domain: &domain::Name, challenge_token: &str, ) -> Result<(), CheckDomainError> { let domain = &domain.inner; // check that the CNAME is installed correctly on the domain { let response = self .client .query(domain, DNSClass::IN, RecordType::CNAME) .map_err(|e| CheckDomainError::Unexpected(Box::from(e)))?; let records = response.answers(); if records.len() != 1 { return Err(CheckDomainError::TargetCNAMENotSet); } // if the single record isn't a CNAME, or it's not the target CNAME, then return // TargetCNAMENotSet match records[0].data() { Some(RData::CNAME(remote_cname)) if remote_cname == &self.target_cname => (), _ => return Err(CheckDomainError::TargetCNAMENotSet), } } // check that the TXT record with the challenge token is correctly installed on the domain { let domain = Name::from_str("_gateway") .map_err(|e| CheckDomainError::Unexpected(Box::from(e)))? .append_domain(domain) .map_err(|e| CheckDomainError::Unexpected(Box::from(e)))?; let response = self .client .query(&domain, DNSClass::IN, RecordType::TXT) .map_err(|e| CheckDomainError::Unexpected(Box::from(e)))?; let records = response.answers(); if !records.iter().any(|record| -> bool { match record.data() { Some(RData::TXT(txt)) => txt.to_string().contains(challenge_token), _ => false, } }) { return Err(CheckDomainError::ChallengeTokenNotSet); } } Ok(()) } }