Implements complete domain verification flow with: - rel=me link verification service - HTML fetching with security controls - Rate limiting to prevent abuse - Email validation utilities - Authorization and verification API endpoints - User-facing templates for authorization and verification flows This completes Phase 2: Domain Verification as designed. Tests: - All Phase 2 unit tests passing - Coverage: 85% overall - Migration tests updated 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
160 lines
5.3 KiB
Python
160 lines
5.3 KiB
Python
"""
|
|
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
|