from __future__ import annotations import asyncio import contextlib import logging import os from collections.abc import AsyncIterator from contextlib import asynccontextmanager from fastapi import FastAPI from starlette.concurrency import run_in_threadpool from starlette.middleware.trustedhost import TrustedHostMiddleware from .ddns_dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns from .ddns_models import DdnsSettings, UpdateResult from .ddns_service import DdnsService from .ddns_store import DdnsStore from .routers import admin, api, assets, management, public, updates from .web.security_headers import RequestBodyLimitMiddleware, install_security_middleware __all__ = [ "DdnsSettings", "DdnsStore", "DnsConfigurationError", "DnsProviderError", "DnsReconciliationError", "UpdateResult", "delete_dns_records", "make_app", "publish_dns", ] OPENAPI_TAGS = [ { "name": "hostnames", "description": "Create KarlDNS hostnames or hostnames under verified domains.", }, { "name": "domains", "description": "Issue and verify DNS TXT challenges for custom domains.", }, { "name": "updates", "description": "Router-facing IP update endpoints for FRITZ!Box and compatible clients.", }, { "name": "management", "description": "API management through private dashboard links. Dashboard passwords protect only the web dashboard, not these API routes.", }, ] CLEANUP_TASK_NAME = "cleanup" RATE_LIMIT_FLUSH_INTERVAL_SECONDS = 10 LOGGER = logging.getLogger(__name__) def make_app(settings: DdnsSettings | None = None) -> FastAPI: settings = settings or DdnsSettings.from_env() settings.validate_launch_ready() store = DdnsStore(settings.database_path) store.ensure_scheduled_task(CLEANUP_TASK_NAME, settings.cleanup_interval_seconds) store.optimize() service = DdnsService(settings, store) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: cleanup_task = asyncio.create_task(_scheduler_loop(store, settings)) rate_limit_task = asyncio.create_task(_rate_limit_flush_loop(store)) try: yield finally: cleanup_task.cancel() rate_limit_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cleanup_task with contextlib.suppress(asyncio.CancelledError): await rate_limit_task await run_in_threadpool(store.flush_rate_limits) await run_in_threadpool(store.close_connections) app = FastAPI( title="KarlDNS", summary="A small self-hosted DynDNS provider for FRITZ!Box routers.", description=( "Generate cryptographically random DynDNS endpoints, verify custom domains with DNS TXT " "records, and publish A/AAAA records through Cloudflare or RFC 2136." ), version="0.1.0", docs_url="/docs", redoc_url="/redoc", openapi_tags=OPENAPI_TAGS, lifespan=lifespan, ) app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.effective_trusted_hosts) app.add_middleware(RequestBodyLimitMiddleware, max_body_bytes=settings.max_request_body_bytes) app.state.settings = settings app.state.store = store app.state.service = service install_security_middleware(app, settings, store) app.include_router(api.router) app.include_router(assets.router) app.include_router(public.router) app.include_router(management.router) app.include_router(admin.make_router(settings.admin_path)) app.include_router(updates.router) return app async def _scheduler_loop(store: DdnsStore, settings: DdnsSettings) -> None: poll_seconds = min(settings.cleanup_interval_seconds, 60) while True: await _run_cleanup_if_due(store, settings) await asyncio.sleep(poll_seconds) async def _rate_limit_flush_loop(store: DdnsStore) -> None: while True: await asyncio.sleep(RATE_LIMIT_FLUSH_INTERVAL_SECONDS) await run_in_threadpool(store.flush_rate_limits) await run_in_threadpool(store.prune_rate_limit_storage) await run_in_threadpool(store.checkpoint_wal) async def _run_cleanup_if_due(store: DdnsStore, settings: DdnsSettings) -> None: lease_owner = f"{os.getpid()}:{id(store)}" lease_seconds = max(60, settings.cleanup_interval_seconds) acquired = await run_in_threadpool( store.acquire_due_task, CLEANUP_TASK_NAME, lease_owner, lease_seconds, ) if not acquired: return try: await run_in_threadpool( store.cleanup, settings.cleanup_challenge_hours, settings.cleanup_unused_account_hours, settings.cleanup_event_history_hours, ) except Exception as exc: LOGGER.exception("Scheduled cleanup failed") await run_in_threadpool(store.increment_metric, "cleanup_errors_total") await run_in_threadpool( store.complete_scheduled_task, CLEANUP_TASK_NAME, settings.cleanup_interval_seconds, False, f"{type(exc).__name__}: {exc}", ) return await run_in_threadpool( store.complete_scheduled_task, CLEANUP_TASK_NAME, settings.cleanup_interval_seconds, True, "", ) await run_in_threadpool(store.increment_metric, "cleanup_success_total") await run_in_threadpool(store.optimize)