""" DNS service for TXT record verification. Provides domain verification via DNS TXT records with system DNS resolver and fallback to public DNS servers. """ import logging import dns.resolver from dns.exception import DNSException logger = logging.getLogger("gondulf.dns") class DNSError(Exception): """Raised when DNS queries fail.""" pass class DNSService: """ DNS resolver service for TXT record verification. Uses system DNS with fallback to public DNS (Google and Cloudflare). """ def __init__(self) -> None: """Initialize DNS service with system resolver and public fallbacks.""" self.resolver = self._create_resolver() logger.debug("DNSService initialized with system resolver") def _create_resolver(self) -> dns.resolver.Resolver: """ Create DNS resolver with system DNS and public fallbacks. Returns: Configured DNS resolver """ resolver = dns.resolver.Resolver() # System DNS is already configured by default # If system DNS fails to load, use public DNS as fallback if not resolver.nameservers: logger.info("System DNS not available, using public DNS fallback") resolver.nameservers = ["8.8.8.8", "1.1.1.1"] else: logger.debug(f"Using system DNS: {resolver.nameservers}") return resolver def get_txt_records(self, domain: str) -> list[str]: """ Query TXT records for a domain. Args: domain: Domain name to query Returns: List of TXT record strings (decoded from bytes) Raises: DNSError: If DNS query fails """ try: logger.debug(f"Querying TXT records for domain={domain}") answers = self.resolver.resolve(domain, "TXT") # Extract and decode TXT records txt_records = [] for rdata in answers: # Each TXT record can have multiple strings, join them txt_value = "".join([s.decode("utf-8") for s in rdata.strings]) txt_records.append(txt_value) logger.info(f"Found {len(txt_records)} TXT record(s) for domain={domain}") return txt_records except dns.resolver.NXDOMAIN: logger.debug(f"Domain does not exist: {domain}") raise DNSError(f"Domain does not exist: {domain}") except dns.resolver.NoAnswer: logger.debug(f"No TXT records found for domain={domain}") return [] # No TXT records is not an error, return empty list except dns.resolver.Timeout: logger.warning(f"DNS query timeout for domain={domain}") raise DNSError(f"DNS query timeout for domain: {domain}") except DNSException as e: logger.error(f"DNS query failed for domain={domain}: {e}") raise DNSError(f"DNS query failed: {e}") from e def verify_txt_record(self, domain: str, expected_value: str) -> bool: """ Verify that domain has a TXT record with the expected value. Args: domain: Domain name to verify expected_value: Expected TXT record value Returns: True if expected value found in TXT records, False otherwise """ try: txt_records = self.get_txt_records(domain) # Check if expected value is in any TXT record for record in txt_records: if expected_value in record: logger.info( f"TXT record verification successful for domain={domain}" ) return True logger.debug( f"TXT record verification failed: expected value not found " f"for domain={domain}" ) return False except DNSError as e: logger.warning(f"TXT record verification failed for domain={domain}: {e}") return False def check_domain_exists(self, domain: str) -> bool: """ Check if a domain exists (has any DNS records). Args: domain: Domain name to check Returns: True if domain exists, False otherwise """ try: # Try to resolve A or AAAA record try: self.resolver.resolve(domain, "A") logger.debug(f"Domain exists (A record): {domain}") return True except dns.resolver.NoAnswer: # Try AAAA if no A record try: self.resolver.resolve(domain, "AAAA") logger.debug(f"Domain exists (AAAA record): {domain}") return True except dns.resolver.NoAnswer: # Try any record type (TXT, MX, etc.) # If NXDOMAIN not raised, domain exists logger.debug(f"Domain exists (other records): {domain}") return True except dns.resolver.NXDOMAIN: logger.debug(f"Domain does not exist: {domain}") return False except DNSException as e: logger.warning(f"DNS check failed for domain={domain}: {e}") # Treat DNS errors as "unknown" - return False to be safe return False