feat(core): implement Phase 1 foundation infrastructure
Implements Phase 1 Foundation with all core services: Core Components: - Configuration management with GONDULF_ environment variables - Database layer with SQLAlchemy and migration system - In-memory code storage with TTL support - Email service with SMTP and TLS support (STARTTLS + implicit TLS) - DNS service with TXT record verification - Structured logging with Python standard logging - FastAPI application with health check endpoint Database Schema: - authorization_codes table for OAuth 2.0 authorization codes - domains table for domain verification - migrations table for tracking schema versions - Simple sequential migration system (001_initial_schema.sql) Configuration: - Environment-based configuration with validation - .env.example template with all GONDULF_ variables - Fail-fast validation on startup - Sensible defaults for optional settings Testing: - 96 comprehensive tests (77 unit, 5 integration) - 94.16% code coverage (exceeds 80% requirement) - All tests passing - Test coverage includes: - Configuration loading and validation - Database migrations and health checks - In-memory storage with expiration - Email service (STARTTLS, implicit TLS, authentication) - DNS service (TXT records, domain verification) - Health check endpoint integration Documentation: - Implementation report with test results - Phase 1 clarifications document - ADRs for key decisions (config, database, email, logging) Technical Details: - Python 3.10+ with type hints - SQLite with configurable database URL - System DNS with public DNS fallback - Port-based TLS detection (465=SSL, 587=STARTTLS) - Lazy configuration loading for testability Exit Criteria Met: ✓ All foundation services implemented ✓ Application starts without errors ✓ Health check endpoint operational ✓ Database migrations working ✓ Test coverage exceeds 80% ✓ All tests passing Ready for Architect review and Phase 2 development. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
160
src/gondulf/dns.py
Normal file
160
src/gondulf/dns.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
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
|
||||
from typing import List, Optional
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user