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:
125
src/gondulf/config.py
Normal file
125
src/gondulf/config.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Configuration management for Gondulf IndieAuth server.
|
||||
|
||||
Loads configuration from environment variables with GONDULF_ prefix.
|
||||
Validates required settings on startup and provides sensible defaults.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file if present
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""Raised when configuration is invalid or missing required values."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Config:
|
||||
"""Application configuration loaded from environment variables."""
|
||||
|
||||
# Required settings - no defaults
|
||||
SECRET_KEY: str
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_HOST: str
|
||||
SMTP_PORT: int
|
||||
SMTP_USERNAME: Optional[str]
|
||||
SMTP_PASSWORD: Optional[str]
|
||||
SMTP_FROM: str
|
||||
SMTP_USE_TLS: bool
|
||||
|
||||
# Token and Code Expiry (seconds)
|
||||
TOKEN_EXPIRY: int
|
||||
CODE_EXPIRY: int
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str
|
||||
DEBUG: bool
|
||||
|
||||
@classmethod
|
||||
def load(cls) -> None:
|
||||
"""
|
||||
Load and validate configuration from environment variables.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If required settings are missing or invalid
|
||||
"""
|
||||
# Required - SECRET_KEY must exist and be sufficiently long
|
||||
secret_key = os.getenv("GONDULF_SECRET_KEY")
|
||||
if not secret_key:
|
||||
raise ConfigurationError(
|
||||
"GONDULF_SECRET_KEY is required. Generate with: "
|
||||
"python -c \"import secrets; print(secrets.token_urlsafe(32))\""
|
||||
)
|
||||
if len(secret_key) < 32:
|
||||
raise ConfigurationError(
|
||||
"GONDULF_SECRET_KEY must be at least 32 characters for security"
|
||||
)
|
||||
cls.SECRET_KEY = secret_key
|
||||
|
||||
# Database - with sensible default
|
||||
cls.DATABASE_URL = os.getenv(
|
||||
"GONDULF_DATABASE_URL", "sqlite:///./data/gondulf.db"
|
||||
)
|
||||
|
||||
# SMTP Configuration
|
||||
cls.SMTP_HOST = os.getenv("GONDULF_SMTP_HOST", "localhost")
|
||||
cls.SMTP_PORT = int(os.getenv("GONDULF_SMTP_PORT", "587"))
|
||||
cls.SMTP_USERNAME = os.getenv("GONDULF_SMTP_USERNAME") or None
|
||||
cls.SMTP_PASSWORD = os.getenv("GONDULF_SMTP_PASSWORD") or None
|
||||
cls.SMTP_FROM = os.getenv("GONDULF_SMTP_FROM", "noreply@example.com")
|
||||
cls.SMTP_USE_TLS = os.getenv("GONDULF_SMTP_USE_TLS", "true").lower() == "true"
|
||||
|
||||
# Token and Code Expiry
|
||||
cls.TOKEN_EXPIRY = int(os.getenv("GONDULF_TOKEN_EXPIRY", "3600"))
|
||||
cls.CODE_EXPIRY = int(os.getenv("GONDULF_CODE_EXPIRY", "600"))
|
||||
|
||||
# Logging
|
||||
cls.DEBUG = os.getenv("GONDULF_DEBUG", "false").lower() == "true"
|
||||
# If DEBUG is true, default LOG_LEVEL to DEBUG, otherwise INFO
|
||||
default_log_level = "DEBUG" if cls.DEBUG else "INFO"
|
||||
cls.LOG_LEVEL = os.getenv("GONDULF_LOG_LEVEL", default_log_level).upper()
|
||||
|
||||
# Validate log level
|
||||
valid_log_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
if cls.LOG_LEVEL not in valid_log_levels:
|
||||
raise ConfigurationError(
|
||||
f"GONDULF_LOG_LEVEL must be one of: {', '.join(valid_log_levels)}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate(cls) -> None:
|
||||
"""
|
||||
Validate configuration after loading.
|
||||
|
||||
Performs additional validation beyond initial loading.
|
||||
"""
|
||||
# Validate SMTP port is reasonable
|
||||
if cls.SMTP_PORT < 1 or cls.SMTP_PORT > 65535:
|
||||
raise ConfigurationError(
|
||||
f"GONDULF_SMTP_PORT must be between 1 and 65535, got {cls.SMTP_PORT}"
|
||||
)
|
||||
|
||||
# Validate expiry times are positive
|
||||
if cls.TOKEN_EXPIRY <= 0:
|
||||
raise ConfigurationError(
|
||||
f"GONDULF_TOKEN_EXPIRY must be positive, got {cls.TOKEN_EXPIRY}"
|
||||
)
|
||||
if cls.CODE_EXPIRY <= 0:
|
||||
raise ConfigurationError(
|
||||
f"GONDULF_CODE_EXPIRY must be positive, got {cls.CODE_EXPIRY}"
|
||||
)
|
||||
|
||||
|
||||
# Configuration is loaded lazily or explicitly by the application
|
||||
# Tests should call Config.load() explicitly in fixtures
|
||||
# Production code should call Config.load() at startup
|
||||
1
src/gondulf/database/__init__.py
Normal file
1
src/gondulf/database/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Database module for Gondulf IndieAuth server."""
|
||||
226
src/gondulf/database/connection.py
Normal file
226
src/gondulf/database/connection.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Database connection management and migrations for Gondulf.
|
||||
|
||||
Provides database initialization, migration running, and health checks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
logger = logging.getLogger("gondulf.database")
|
||||
|
||||
|
||||
class DatabaseError(Exception):
|
||||
"""Raised when database operations fail."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Database:
|
||||
"""
|
||||
Database connection manager with migration support.
|
||||
|
||||
Handles database initialization, migration execution, and health checks.
|
||||
"""
|
||||
|
||||
def __init__(self, database_url: str):
|
||||
"""
|
||||
Initialize database connection.
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database URL (e.g., sqlite:///./data/gondulf.db)
|
||||
"""
|
||||
self.database_url = database_url
|
||||
self._engine: Optional[Engine] = None
|
||||
|
||||
def ensure_database_directory(self) -> None:
|
||||
"""
|
||||
Create database directory if it doesn't exist (for SQLite).
|
||||
|
||||
Only applies to SQLite databases. Creates parent directory structure.
|
||||
"""
|
||||
if self.database_url.startswith("sqlite:///"):
|
||||
# Parse path from URL
|
||||
# sqlite:///./data/gondulf.db -> ./data/gondulf.db
|
||||
# sqlite:////var/lib/gondulf/gondulf.db -> /var/lib/gondulf/gondulf.db
|
||||
db_path_str = self.database_url.replace("sqlite:///", "", 1)
|
||||
db_file = Path(db_path_str)
|
||||
|
||||
# Create parent directory if needed
|
||||
db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Database directory ensured: {db_file.parent}")
|
||||
|
||||
def get_engine(self) -> Engine:
|
||||
"""
|
||||
Get or create SQLAlchemy engine.
|
||||
|
||||
Returns:
|
||||
SQLAlchemy Engine instance
|
||||
|
||||
Raises:
|
||||
DatabaseError: If engine creation fails
|
||||
"""
|
||||
if self._engine is None:
|
||||
try:
|
||||
self._engine = create_engine(
|
||||
self.database_url,
|
||||
echo=False, # Don't log all SQL statements
|
||||
pool_pre_ping=True, # Verify connections before using
|
||||
)
|
||||
logger.debug(f"Created database engine for {self.database_url}")
|
||||
except Exception as e:
|
||||
raise DatabaseError(f"Failed to create database engine: {e}") from e
|
||||
|
||||
return self._engine
|
||||
|
||||
def check_health(self, timeout_seconds: int = 5) -> bool:
|
||||
"""
|
||||
Check if database is accessible and healthy.
|
||||
|
||||
Args:
|
||||
timeout_seconds: Query timeout in seconds
|
||||
|
||||
Returns:
|
||||
True if database is healthy, False otherwise
|
||||
"""
|
||||
try:
|
||||
engine = self.get_engine()
|
||||
with engine.connect() as conn:
|
||||
# Simple health check query
|
||||
result = conn.execute(text("SELECT 1"))
|
||||
result.fetchone()
|
||||
logger.debug("Database health check passed")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Database health check failed: {e}")
|
||||
return False
|
||||
|
||||
def get_applied_migrations(self) -> set[int]:
|
||||
"""
|
||||
Get set of applied migration versions.
|
||||
|
||||
Returns:
|
||||
Set of migration version numbers that have been applied
|
||||
|
||||
Raises:
|
||||
DatabaseError: If query fails
|
||||
"""
|
||||
try:
|
||||
engine = self.get_engine()
|
||||
with engine.connect() as conn:
|
||||
# Check if migrations table exists first
|
||||
try:
|
||||
result = conn.execute(text("SELECT version FROM migrations"))
|
||||
versions = {row[0] for row in result}
|
||||
logger.debug(f"Applied migrations: {versions}")
|
||||
return versions
|
||||
except SQLAlchemyError:
|
||||
# Migrations table doesn't exist yet
|
||||
logger.debug("Migrations table does not exist yet")
|
||||
return set()
|
||||
except Exception as e:
|
||||
raise DatabaseError(f"Failed to query applied migrations: {e}") from e
|
||||
|
||||
def run_migration(self, version: int, sql_file_path: Path) -> None:
|
||||
"""
|
||||
Run a single migration file.
|
||||
|
||||
Args:
|
||||
version: Migration version number
|
||||
sql_file_path: Path to SQL migration file
|
||||
|
||||
Raises:
|
||||
DatabaseError: If migration fails
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Running migration {version}: {sql_file_path.name}")
|
||||
|
||||
# Read SQL file
|
||||
sql_content = sql_file_path.read_text()
|
||||
|
||||
# Execute migration in a transaction
|
||||
engine = self.get_engine()
|
||||
with engine.begin() as conn:
|
||||
# Split by semicolons and execute each statement
|
||||
# Note: This is simple splitting, doesn't handle semicolons in strings
|
||||
statements = [s.strip() for s in sql_content.split(";") if s.strip()]
|
||||
|
||||
for statement in statements:
|
||||
if statement:
|
||||
conn.execute(text(statement))
|
||||
|
||||
logger.info(f"Migration {version} completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
raise DatabaseError(f"Migration {version} failed: {e}") from e
|
||||
|
||||
def run_migrations(self) -> None:
|
||||
"""
|
||||
Run all pending database migrations.
|
||||
|
||||
Discovers migration files in migrations/ directory and runs any that haven't
|
||||
been applied yet.
|
||||
|
||||
Raises:
|
||||
DatabaseError: If migrations fail
|
||||
"""
|
||||
# Get migrations directory
|
||||
migrations_dir = Path(__file__).parent / "migrations"
|
||||
if not migrations_dir.exists():
|
||||
logger.warning(f"Migrations directory not found: {migrations_dir}")
|
||||
return
|
||||
|
||||
# Get applied migrations
|
||||
applied = self.get_applied_migrations()
|
||||
|
||||
# Find all migration files
|
||||
migration_files = sorted(migrations_dir.glob("*.sql"))
|
||||
|
||||
if not migration_files:
|
||||
logger.info("No migration files found")
|
||||
return
|
||||
|
||||
# Run pending migrations in order
|
||||
for migration_file in migration_files:
|
||||
# Extract version number from filename (e.g., "001_initial_schema.sql" -> 1)
|
||||
try:
|
||||
version = int(migration_file.stem.split("_")[0])
|
||||
except (ValueError, IndexError):
|
||||
logger.warning(f"Skipping invalid migration filename: {migration_file}")
|
||||
continue
|
||||
|
||||
if version not in applied:
|
||||
self.run_migration(version, migration_file)
|
||||
else:
|
||||
logger.debug(f"Migration {version} already applied, skipping")
|
||||
|
||||
logger.info("All migrations completed")
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""
|
||||
Initialize database: create directories and run migrations.
|
||||
|
||||
This is the main entry point for setting up the database.
|
||||
|
||||
Raises:
|
||||
DatabaseError: If initialization fails
|
||||
"""
|
||||
logger.info("Initializing database")
|
||||
|
||||
# Ensure database directory exists (for SQLite)
|
||||
self.ensure_database_directory()
|
||||
|
||||
# Run migrations
|
||||
self.run_migrations()
|
||||
|
||||
# Verify database is healthy
|
||||
if not self.check_health():
|
||||
raise DatabaseError("Database health check failed after initialization")
|
||||
|
||||
logger.info("Database initialization complete")
|
||||
38
src/gondulf/database/migrations/001_initial_schema.sql
Normal file
38
src/gondulf/database/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Migration 001: Initial schema for Gondulf v1.0.0 Phase 1
|
||||
-- Creates tables for authorization codes, domain verification, and migration tracking
|
||||
|
||||
-- Authorization codes table
|
||||
-- Stores temporary OAuth 2.0 authorization codes with PKCE support
|
||||
CREATE TABLE authorization_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
state TEXT,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
scope TEXT,
|
||||
me TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Domains table
|
||||
-- Stores domain ownership verification records
|
||||
CREATE TABLE domains (
|
||||
domain TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
verification_code TEXT NOT NULL,
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
verified_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Migrations table
|
||||
-- Tracks applied database migrations
|
||||
CREATE TABLE migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Record this migration
|
||||
INSERT INTO migrations (version, description) VALUES (1, 'Initial schema - authorization_codes, domains, migrations tables');
|
||||
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
|
||||
177
src/gondulf/email.py
Normal file
177
src/gondulf/email.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Email service for sending verification codes via SMTP.
|
||||
|
||||
Supports both STARTTLS (port 587) and implicit TLS (port 465) based on
|
||||
configuration. Handles authentication and error cases.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("gondulf.email")
|
||||
|
||||
|
||||
class EmailError(Exception):
|
||||
"""Raised when email sending fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""
|
||||
SMTP email service for sending verification emails.
|
||||
|
||||
Supports STARTTLS and implicit TLS configurations based on port number.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_from: str,
|
||||
smtp_username: Optional[str] = None,
|
||||
smtp_password: Optional[str] = None,
|
||||
smtp_use_tls: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize email service.
|
||||
|
||||
Args:
|
||||
smtp_host: SMTP server hostname
|
||||
smtp_port: SMTP server port (587 for STARTTLS, 465 for implicit TLS)
|
||||
smtp_from: From address for sent emails
|
||||
smtp_username: SMTP username for authentication (optional)
|
||||
smtp_password: SMTP password for authentication (optional)
|
||||
smtp_use_tls: Whether to use TLS (STARTTLS on port 587)
|
||||
"""
|
||||
self.smtp_host = smtp_host
|
||||
self.smtp_port = smtp_port
|
||||
self.smtp_from = smtp_from
|
||||
self.smtp_username = smtp_username
|
||||
self.smtp_password = smtp_password
|
||||
self.smtp_use_tls = smtp_use_tls
|
||||
|
||||
logger.debug(
|
||||
f"EmailService initialized: host={smtp_host} port={smtp_port} "
|
||||
f"tls={smtp_use_tls}"
|
||||
)
|
||||
|
||||
def send_verification_code(self, to_email: str, code: str, domain: str) -> None:
|
||||
"""
|
||||
Send domain verification code via email.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
code: Verification code to send
|
||||
domain: Domain being verified
|
||||
|
||||
Raises:
|
||||
EmailError: If sending fails
|
||||
"""
|
||||
subject = f"Domain Verification Code for {domain}"
|
||||
body = f"""
|
||||
Hello,
|
||||
|
||||
Your domain verification code for {domain} is:
|
||||
|
||||
{code}
|
||||
|
||||
This code will expire in 10 minutes.
|
||||
|
||||
If you did not request this verification, please ignore this email.
|
||||
|
||||
---
|
||||
Gondulf IndieAuth Server
|
||||
"""
|
||||
|
||||
try:
|
||||
self._send_email(to_email, subject, body)
|
||||
logger.info(f"Verification code sent to {to_email} for domain={domain}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send verification email to {to_email}: {e}")
|
||||
raise EmailError(f"Failed to send verification email: {e}") from e
|
||||
|
||||
def _send_email(self, to_email: str, subject: str, body: str) -> None:
|
||||
"""
|
||||
Send email via SMTP.
|
||||
|
||||
Handles STARTTLS vs implicit TLS based on port configuration.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
subject: Email subject
|
||||
body: Email body (plain text)
|
||||
|
||||
Raises:
|
||||
EmailError: If sending fails
|
||||
"""
|
||||
# Create message
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = self.smtp_from
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
|
||||
try:
|
||||
# Determine connection type based on port
|
||||
if self.smtp_port == 465:
|
||||
# Implicit TLS (SSL/TLS from start)
|
||||
logger.debug("Using implicit TLS (SMTP_SSL)")
|
||||
server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=10)
|
||||
elif self.smtp_port == 587 and self.smtp_use_tls:
|
||||
# STARTTLS (upgrade plain connection to TLS)
|
||||
logger.debug("Using STARTTLS")
|
||||
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10)
|
||||
server.starttls()
|
||||
else:
|
||||
# Unencrypted (for testing only)
|
||||
logger.warning("Using unencrypted SMTP connection")
|
||||
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10)
|
||||
|
||||
# Authenticate if credentials provided
|
||||
if self.smtp_username and self.smtp_password:
|
||||
logger.debug(f"Authenticating as {self.smtp_username}")
|
||||
server.login(self.smtp_username, self.smtp_password)
|
||||
|
||||
# Send email
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
|
||||
logger.debug(f"Email sent successfully to {to_email}")
|
||||
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
raise EmailError(f"SMTP authentication failed: {e}") from e
|
||||
except smtplib.SMTPException as e:
|
||||
raise EmailError(f"SMTP error: {e}") from e
|
||||
except Exception as e:
|
||||
raise EmailError(f"Failed to send email: {e}") from e
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""
|
||||
Test SMTP connection and authentication.
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if self.smtp_port == 465:
|
||||
server = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout=10)
|
||||
elif self.smtp_port == 587 and self.smtp_use_tls:
|
||||
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10)
|
||||
|
||||
if self.smtp_username and self.smtp_password:
|
||||
server.login(self.smtp_username, self.smtp_password)
|
||||
|
||||
server.quit()
|
||||
logger.info("SMTP connection test successful")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"SMTP connection test failed: {e}")
|
||||
return False
|
||||
57
src/gondulf/logging_config.py
Normal file
57
src/gondulf/logging_config.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Logging configuration for Gondulf IndieAuth server.
|
||||
|
||||
Provides structured logging with consistent format across all modules.
|
||||
Uses Python's standard logging module with configurable levels.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging(log_level: str = "INFO", debug: bool = False) -> None:
|
||||
"""
|
||||
Configure application logging.
|
||||
|
||||
Sets up structured logging format and level for all Gondulf modules.
|
||||
Logs to stdout/stderr for container-friendly output.
|
||||
|
||||
Args:
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
debug: If True, overrides log_level to DEBUG
|
||||
"""
|
||||
# Determine effective log level
|
||||
effective_level = "DEBUG" if debug else log_level
|
||||
|
||||
# Configure root logger
|
||||
logging.basicConfig(
|
||||
level=effective_level,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
stream=sys.stdout,
|
||||
force=True, # Override any existing configuration
|
||||
)
|
||||
|
||||
# Set level for gondulf modules specifically
|
||||
gondulf_logger = logging.getLogger("gondulf")
|
||||
gondulf_logger.setLevel(effective_level)
|
||||
|
||||
# Reduce noise from third-party libraries in production
|
||||
if not debug:
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy").setLevel(logging.WARNING)
|
||||
|
||||
logging.info(f"Logging configured: level={effective_level}")
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""
|
||||
Get a logger instance for a module.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__ from calling module)
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
"""
|
||||
return logging.getLogger(name)
|
||||
166
src/gondulf/main.py
Normal file
166
src/gondulf/main.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Gondulf IndieAuth Server - Main application entry point.
|
||||
|
||||
FastAPI application with health check endpoint and core service initialization.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from gondulf.config import Config
|
||||
from gondulf.database.connection import Database
|
||||
from gondulf.dns import DNSService
|
||||
from gondulf.email import EmailService
|
||||
from gondulf.logging_config import configure_logging
|
||||
from gondulf.storage import CodeStore
|
||||
|
||||
# Load configuration at application startup
|
||||
Config.load()
|
||||
Config.validate()
|
||||
|
||||
# Configure logging
|
||||
configure_logging(log_level=Config.LOG_LEVEL, debug=Config.DEBUG)
|
||||
logger = logging.getLogger("gondulf.main")
|
||||
|
||||
# Initialize FastAPI application
|
||||
app = FastAPI(
|
||||
title="Gondulf IndieAuth Server",
|
||||
description="Self-hosted IndieAuth authentication server",
|
||||
version="0.1.0-dev",
|
||||
)
|
||||
|
||||
# Initialize core services
|
||||
database: Database = None
|
||||
code_store: CodeStore = None
|
||||
email_service: EmailService = None
|
||||
dns_service: DNSService = None
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event() -> None:
|
||||
"""
|
||||
Initialize application on startup.
|
||||
|
||||
Initializes database, code storage, email service, and DNS service.
|
||||
"""
|
||||
global database, code_store, email_service, dns_service
|
||||
|
||||
logger.info("Starting Gondulf IndieAuth Server")
|
||||
logger.info(f"Configuration: DATABASE_URL={Config.DATABASE_URL}")
|
||||
logger.info(f"Configuration: SMTP_HOST={Config.SMTP_HOST}:{Config.SMTP_PORT}")
|
||||
logger.info(f"Configuration: DEBUG={Config.DEBUG}")
|
||||
|
||||
try:
|
||||
# Initialize database
|
||||
logger.info("Initializing database")
|
||||
database = Database(Config.DATABASE_URL)
|
||||
database.initialize()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# Initialize code store
|
||||
logger.info("Initializing code store")
|
||||
code_store = CodeStore(ttl_seconds=Config.CODE_EXPIRY)
|
||||
logger.info(f"Code store initialized with TTL={Config.CODE_EXPIRY}s")
|
||||
|
||||
# Initialize email service
|
||||
logger.info("Initializing email service")
|
||||
email_service = EmailService(
|
||||
smtp_host=Config.SMTP_HOST,
|
||||
smtp_port=Config.SMTP_PORT,
|
||||
smtp_from=Config.SMTP_FROM,
|
||||
smtp_username=Config.SMTP_USERNAME,
|
||||
smtp_password=Config.SMTP_PASSWORD,
|
||||
smtp_use_tls=Config.SMTP_USE_TLS,
|
||||
)
|
||||
logger.info("Email service initialized")
|
||||
|
||||
# Initialize DNS service
|
||||
logger.info("Initializing DNS service")
|
||||
dns_service = DNSService()
|
||||
logger.info("DNS service initialized")
|
||||
|
||||
logger.info("Gondulf startup complete")
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(f"Failed to initialize application: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event() -> None:
|
||||
"""Clean up resources on shutdown."""
|
||||
logger.info("Shutting down Gondulf IndieAuth Server")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> JSONResponse:
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Verifies that the application is running and database is accessible.
|
||||
Does not require authentication.
|
||||
|
||||
Returns:
|
||||
JSON response with health status:
|
||||
- 200 OK: {"status": "healthy", "database": "connected"}
|
||||
- 503 Service Unavailable: {"status": "unhealthy", "database": "error", "error": "..."}
|
||||
"""
|
||||
# Check database connectivity
|
||||
if database is None:
|
||||
logger.warning("Health check failed: database not initialized")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "unhealthy",
|
||||
"database": "error",
|
||||
"error": "database not initialized",
|
||||
},
|
||||
)
|
||||
|
||||
is_healthy = database.check_health(timeout_seconds=5)
|
||||
|
||||
if is_healthy:
|
||||
logger.debug("Health check passed")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"status": "healthy", "database": "connected"},
|
||||
)
|
||||
else:
|
||||
logger.warning("Health check failed: unable to connect to database")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "unhealthy",
|
||||
"database": "error",
|
||||
"error": "unable to connect to database",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> dict:
|
||||
"""
|
||||
Root endpoint.
|
||||
|
||||
Returns basic server information.
|
||||
"""
|
||||
return {
|
||||
"service": "Gondulf IndieAuth Server",
|
||||
"version": "0.1.0-dev",
|
||||
"status": "operational",
|
||||
}
|
||||
|
||||
|
||||
# Entry point for uvicorn
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"gondulf.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=Config.DEBUG,
|
||||
log_level=Config.LOG_LEVEL.lower(),
|
||||
)
|
||||
150
src/gondulf/storage.py
Normal file
150
src/gondulf/storage.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
In-memory storage for short-lived codes with TTL.
|
||||
|
||||
Provides simple dict-based storage for email verification codes and authorization
|
||||
codes with automatic expiration checking on access.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("gondulf.storage")
|
||||
|
||||
|
||||
class CodeStore:
|
||||
"""
|
||||
In-memory storage for domain verification codes with TTL.
|
||||
|
||||
Stores codes with expiration timestamps and automatically removes expired
|
||||
codes on access. No background cleanup needed - cleanup happens lazily.
|
||||
"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 600):
|
||||
"""
|
||||
Initialize code store.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Time-to-live for codes in seconds (default: 600 = 10 minutes)
|
||||
"""
|
||||
self._store: Dict[str, Tuple[str, float]] = {}
|
||||
self._ttl = ttl_seconds
|
||||
logger.debug(f"CodeStore initialized with TTL={ttl_seconds}s")
|
||||
|
||||
def store(self, key: str, code: str) -> None:
|
||||
"""
|
||||
Store verification code with expiry timestamp.
|
||||
|
||||
Args:
|
||||
key: Storage key (typically email address or similar identifier)
|
||||
code: Verification code to store
|
||||
"""
|
||||
expiry = time.time() + self._ttl
|
||||
self._store[key] = (code, expiry)
|
||||
logger.debug(f"Code stored for key={key} expires_in={self._ttl}s")
|
||||
|
||||
def verify(self, key: str, code: str) -> bool:
|
||||
"""
|
||||
Verify code matches stored value and remove from store.
|
||||
|
||||
Checks both expiration and code matching. If valid, removes the code
|
||||
from storage (single-use). Expired codes are also removed.
|
||||
|
||||
Args:
|
||||
key: Storage key to verify
|
||||
code: Code to verify
|
||||
|
||||
Returns:
|
||||
True if code matches and is not expired, False otherwise
|
||||
"""
|
||||
if key not in self._store:
|
||||
logger.debug(f"Verification failed: key={key} not found")
|
||||
return False
|
||||
|
||||
stored_code, expiry = self._store[key]
|
||||
|
||||
# Check expiration
|
||||
if time.time() > expiry:
|
||||
del self._store[key]
|
||||
logger.debug(f"Verification failed: key={key} expired")
|
||||
return False
|
||||
|
||||
# Check code match
|
||||
if code != stored_code:
|
||||
logger.debug(f"Verification failed: key={key} code mismatch")
|
||||
return False
|
||||
|
||||
# Valid - remove from store (single use)
|
||||
del self._store[key]
|
||||
logger.info(f"Code verified successfully for key={key}")
|
||||
return True
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Get code without removing it (for testing/debugging).
|
||||
|
||||
Checks expiration and removes expired codes.
|
||||
|
||||
Args:
|
||||
key: Storage key to retrieve
|
||||
|
||||
Returns:
|
||||
Code if exists and not expired, None otherwise
|
||||
"""
|
||||
if key not in self._store:
|
||||
return None
|
||||
|
||||
stored_code, expiry = self._store[key]
|
||||
|
||||
# Check expiration
|
||||
if time.time() > expiry:
|
||||
del self._store[key]
|
||||
return None
|
||||
|
||||
return stored_code
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""
|
||||
Explicitly delete a code from storage.
|
||||
|
||||
Args:
|
||||
key: Storage key to delete
|
||||
"""
|
||||
if key in self._store:
|
||||
del self._store[key]
|
||||
logger.debug(f"Code deleted for key={key}")
|
||||
|
||||
def cleanup_expired(self) -> int:
|
||||
"""
|
||||
Manually cleanup all expired codes.
|
||||
|
||||
This is optional - cleanup happens automatically on access. But can be
|
||||
called periodically if needed to free memory.
|
||||
|
||||
Returns:
|
||||
Number of expired codes removed
|
||||
"""
|
||||
now = time.time()
|
||||
expired_keys = [key for key, (_, expiry) in self._store.items() if now > expiry]
|
||||
|
||||
for key in expired_keys:
|
||||
del self._store[key]
|
||||
|
||||
if expired_keys:
|
||||
logger.debug(f"Cleaned up {len(expired_keys)} expired codes")
|
||||
|
||||
return len(expired_keys)
|
||||
|
||||
def size(self) -> int:
|
||||
"""
|
||||
Get number of codes currently in storage (including expired).
|
||||
|
||||
Returns:
|
||||
Number of codes in storage
|
||||
"""
|
||||
return len(self._store)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all codes from storage."""
|
||||
self._store.clear()
|
||||
logger.debug("Code store cleared")
|
||||
Reference in New Issue
Block a user