Archived
Prepare KarlDNS production deployment
This commit is contained in:
1 parent
5a90bd9d87
commit
ce5b4e0ff2
101 files changed
+3364
-2018
No files matched your search
+160
@@ -0,0 +1,160 @@
|
||||
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.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.",
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Operator API endpoints protected by the same admin HTTP Basic auth as the web admin console.",
|
||||
},
|
||||
]
|
||||
|
||||
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.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_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)
|
||||
Reference in new issue
Block a user