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:
2025-11-20 12:21:42 -07:00
parent 7255867fde
commit bebd47955f
39 changed files with 8134 additions and 13 deletions

160
src/gondulf/dns.py Normal file
View 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