Files
Gondulf/src/gondulf/dns.py
Phil Skentelbery 074f74002c feat(phase-2): implement domain verification system
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>
2025-11-20 13:44:33 -07:00

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