Archived
Preserve the GitHub history and the newer deployed source from ct_134_karldns. Exclude runtime data, credentials, AppleDouble files and local backup copies. Make Gitea the canonical source for documentation, package metadata and project links.
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 .dns import DnsConfigurationError, DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
|
|
from .models import UpdateResult
|
|
from .routers import admin, api, assets, management, public, updates
|
|
from .services.ddns import DdnsService
|
|
from .settings import DdnsSettings
|
|
from .store import DdnsStore
|
|
from .web.request_capture import PostCaptureMiddleware
|
|
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 that can be used directly or as CNAME targets.",
|
|
},
|
|
{
|
|
"name": "router",
|
|
"description": "Router-facing registration and IP update endpoints for simple URL clients.",
|
|
},
|
|
]
|
|
|
|
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 and publish A/AAAA records "
|
|
"for KarlDNS-managed hostnames through Cloudflare or RFC 2136."
|
|
),
|
|
version="0.1.0",
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
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.add_middleware(PostCaptureMiddleware, settings=settings)
|
|
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:
|
|
try:
|
|
await _run_cleanup_if_due(store, settings)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
LOGGER.exception("Scheduled cleanup loop crashed; continuing")
|
|
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_unused_account_hours,
|
|
settings.cleanup_event_history_hours,
|
|
settings.cleanup_pending_account_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)
|