Archived
164 lines
5.5 KiB
Python
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)
|