This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/router_dyndns/app.py
T

164 lines
5.5 KiB
Python

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)