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/admin.py
T

198 lines
7.1 KiB
Python

from __future__ import annotations
from typing import Annotated
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
from ..dns import DnsProviderError
from ..event_statuses import (
ADMIN_HOSTNAME_DELETED,
ADMIN_MANAGEMENT_URL_ROTATED,
ADMIN_UPDATE_URL_ROTATED,
)
from ..security import client_ip, require_admin_csrf
from ..services.ddns import normalize_hostname
from ..web.components import _copy_row
from ..web.forms import first_form_value, request_form
from ..web.html_utils import SafeHtml
from ..web.templates_admin import _admin_notice, _admin_page_context, _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100
ADMIN_JSON_OFFSET_CAP = 10_000
AdminAuthDep = Annotated[None, Depends(authenticate_admin)]
AdminOffsetQuery = Annotated[int, Query(ge=0)]
CleanupDoneQuery = Annotated[bool, Query()]
CleanupCountQuery = Annotated[int, Query(ge=0)]
def _admin_table_offsets(
accounts_offset: AdminOffsetQuery = 0,
cleanup_runs_offset: AdminOffsetQuery = 0,
update_events_offset: AdminOffsetQuery = 0,
) -> dict[str, int]:
return {
"accounts": accounts_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
}
def _admin_cleanup_notice(
cleanup_done: CleanupDoneQuery = False,
cleanup_unused: CleanupCountQuery = 0,
cleanup_events: CleanupCountQuery = 0,
) -> SafeHtml | None:
if not cleanup_done:
return None
return _admin_notice(
"success",
f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events.",
)
AdminTableOffsetsDep = Annotated[dict[str, int], Depends(_admin_table_offsets)]
AdminCleanupNoticeDep = Annotated[SafeHtml | None, Depends(_admin_cleanup_notice)]
def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter()
def render_admin(
settings: SettingsDep,
store: StoreDep,
table_offsets: dict[str, int] | None = None,
notice: SafeHtml | None = None,
) -> str:
table_offsets = table_offsets or {}
context = _admin_page_context(store, table_offsets, ADMIN_TABLE_LIMIT, notice)
return _render_admin_page(settings, context)
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
def admin(
settings: SettingsDep,
store: StoreDep,
_: AdminAuthDep,
table_offsets: AdminTableOffsetsDep,
notice: AdminCleanupNoticeDep,
) -> str:
return render_admin(settings, store, table_offsets, notice)
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
async def admin_delete_account(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
hostname = normalize_hostname(first_form_value(form, "hostname"), settings, expand_suffix=False)
if hostname:
try:
await run_in_threadpool(service.delete_account, hostname, "admin")
except DnsProviderError:
return PlainTextResponse("DNS delete failed; hostname was not deleted", status_code=500)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_HOSTNAME_DELETED,
"hostname deleted by admin",
client_ip(request, settings),
)
return RedirectResponse(admin_path, status_code=303)
@router.post(f"{admin_path}/hostnames/rotate", include_in_schema=False)
async def admin_rotate_account(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
hostname = normalize_hostname(first_form_value(form, "hostname"), settings, expand_suffix=False)
action = first_form_value(form, "action")
account = await run_in_threadpool(store.get_account_by_hostname, hostname)
if not account:
raise HTTPException(status_code=404, detail="hostname not found")
notice = None
if action == "update":
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_UPDATE_URL_ROTATED,
"router update URL rotated by admin",
client_ip(request, settings),
)
notice = (
_admin_secret_panel(
"Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated), "Secret")]
)
if rotated
else None
)
elif action == "management":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_MANAGEMENT_URL_ROTATED,
"dashboard link rotated by admin",
client_ip(request, settings),
)
notice = (
_admin_secret_panel(
"Dashboard link changed",
[_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")],
)
if rotated
else None
)
else:
raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, settings, store, None, notice)
return HTMLResponse(page)
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
async def admin_run_cleanup(
request: Request,
settings: SettingsDep,
store: StoreDep,
_: AdminAuthDep,
) -> Response:
form = await request_form(request, settings)
require_admin_csrf(settings, first_form_value(form, "csrf"))
result = await run_in_threadpool(
store.cleanup,
settings.cleanup_unused_account_hours,
settings.cleanup_event_history_hours,
settings.cleanup_pending_account_hours,
)
redirect_query = urlencode(
{
"cleanup_done": "1",
"cleanup_unused": result["unused_accounts"],
"cleanup_events": result["update_events"],
}
)
return RedirectResponse(f"{admin_path}?{redirect_query}", status_code=303)
return router
router = make_router()