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/karldns/routers/api.py
T

224 lines
8.6 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, StoreDep, authenticate_admin
from ..dns import DnsProviderError
from ..event_statuses import DASHBOARD_UPDATE_URL_ROTATED
from ..schemas import (
AdminDashboardResponse,
AdminEventsResponse,
AdminHostnamesResponse,
AdminRecordsResponse,
AdminTotals,
ErrorResponse,
HostnameCreateResponse,
HostnameRotateUpdateResponse,
HostnameStatusResponse,
_admin_hostname,
_admin_metrics,
_create_response,
_hostname_rotate_update_response,
_hostname_status,
)
from ..security import client_ip
from .path_params import ManagementSlugPath
router = APIRouter(prefix="/api/v1")
ADMIN_API_LIMIT_CAP = 500
ADMIN_API_OFFSET_CAP = 10_000
HOSTNAME_HISTORY_LIMIT_CAP = 100
@router.post(
"/hostnames",
response_model=HostnameCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a KarlDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_hostname(service: ServiceDep) -> HostnameCreateResponse:
account = await run_in_threadpool(service.create_managed_account)
return _create_response(service, account)
@router.get(
"/hostnames/{dashboard_secret}",
response_model=HostnameStatusResponse,
summary="Read a hostname by private dashboard link",
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
tags=["hostnames"],
responses={404: {"model": ErrorResponse}},
)
async def read_hostname(
dashboard_secret: ManagementSlugPath,
store: StoreDep,
service: ServiceDep,
history_limit: int = Query(10, ge=0, le=HOSTNAME_HISTORY_LIMIT_CAP),
) -> HostnameStatusResponse:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
account["management_slug"] = dashboard_secret
history = await run_in_threadpool(store.list_update_events_for_hostname, str(account["hostname"]), history_limit) if history_limit else []
return _hostname_status(service, account, history)
@router.post(
"/hostnames/{dashboard_secret}/rotate-update-url",
response_model=HostnameRotateUpdateResponse,
summary="Rotate a hostname update URL",
description="Uses the private dashboard secret. The old router update URL stops working immediately.",
tags=["hostnames"],
responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def rotate_hostname_update_url(
dashboard_secret: ManagementSlugPath,
request: Request,
store: StoreDep,
service: ServiceDep,
) -> HostnameRotateUpdateResponse:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
if not account["accepted_at"]:
raise HTTPException(status_code=400, detail="register hostname before rotating update URL")
hostname = str(account["hostname"])
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
if not rotated:
raise HTTPException(status_code=404, detail="not found")
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_UPDATE_URL_ROTATED,
"router update URL rotated by API",
client_ip(request, service.settings),
)
return _hostname_rotate_update_response(service, rotated, dashboard_secret)
@router.delete(
"/hostnames/{dashboard_secret}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by private dashboard link",
description="Uses the private dashboard secret. Dashboard passwords protect only the web dashboard, not this API endpoint.",
tags=["hostnames"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_hostname(dashboard_secret: ManagementSlugPath, store: StoreDep, service: ServiceDep) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "api")
except DnsProviderError as exc:
raise HTTPException(status_code=500, detail="DNS delete failed. Hostname was not deleted.") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/admin/dashboard",
response_model=AdminDashboardResponse,
summary="Read operator dashboard summary",
description="Requires admin HTTP Basic auth. Returns the same high-level data shown in the operator console.",
tags=["admin"],
responses={401: {"model": ErrorResponse}},
)
async def read_admin(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(25, ge=1, le=ADMIN_API_LIMIT_CAP),
) -> AdminDashboardResponse:
metrics = await run_in_threadpool(store.get_service_metrics)
accounts = await run_in_threadpool(store.list_accounts, limit, 0)
cleanup_runs = await run_in_threadpool(store.list_cleanup_runs, limit, 0)
scheduled_tasks = await run_in_threadpool(store.list_scheduled_tasks)
events = await run_in_threadpool(store.list_update_events, limit, 0)
return AdminDashboardResponse(
totals=AdminTotals(
hostnames=await run_in_threadpool(store.count_accounts),
records=await run_in_threadpool(store.count_records),
update_events=await run_in_threadpool(store.count_update_events),
cleanup_runs=await run_in_threadpool(store.count_cleanup_runs),
),
metrics=_admin_metrics(metrics),
hostnames=[_admin_hostname(account) for account in accounts],
update_events=events,
cleanup_runs=cleanup_runs,
scheduled_tasks=scheduled_tasks,
limit=limit,
)
@router.get(
"/admin/hostnames",
response_model=AdminHostnamesResponse,
summary="List operator hostnames",
description="Requires admin HTTP Basic auth.",
tags=["admin"],
responses={401: {"model": ErrorResponse}},
)
async def list_admin_hostnames(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
) -> AdminHostnamesResponse:
accounts = await run_in_threadpool(store.list_accounts, limit, offset)
total = await run_in_threadpool(store.count_accounts)
return AdminHostnamesResponse(
items=[_admin_hostname(account) for account in accounts],
limit=limit,
offset=offset,
total=total,
)
@router.get(
"/admin/events",
response_model=AdminEventsResponse,
summary="List operator update history",
description="Requires admin HTTP Basic auth. Supports offset pagination or keyset pagination with before.",
tags=["admin"],
responses={401: {"model": ErrorResponse}},
)
async def list_admin_events(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
before: str | None = Query(None, max_length=64),
) -> AdminEventsResponse:
items = (
await run_in_threadpool(store.list_update_events_before, limit, before)
if before
else await run_in_threadpool(store.list_update_events_page, limit, offset)
)
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
total = await run_in_threadpool(store.count_update_events)
return AdminEventsResponse(items=items, limit=limit, offset=offset, next_before=next_before, total=total)
@router.get(
"/admin/records",
response_model=AdminRecordsResponse,
summary="List operator DNS records",
description="Requires admin HTTP Basic auth.",
tags=["admin"],
responses={401: {"model": ErrorResponse}},
)
async def list_admin_records(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=ADMIN_API_LIMIT_CAP),
offset: int = Query(0, ge=0, le=ADMIN_API_OFFSET_CAP),
) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset)
total = await run_in_threadpool(store.count_records)
return AdminRecordsResponse(items=items, limit=limit, offset=offset, total=total)