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

236 lines
9.3 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from ..ddns_dns import DnsProviderError
from ..ddns_security import client_ip, require_admin_csrf
from ..ddns_service import normalize_hostname
from ..dependencies import ServiceDep, SettingsDep, StoreDep, authenticate_admin
from ..event_statuses import (
ADMIN_HOSTNAME_DELETED,
ADMIN_MANAGEMENT_URL_ROTATED,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
ADMIN_UPDATE_URL_ROTATED,
)
from ..web.forms import first_form_value, request_form
from ..web.templates import _copy_row
from ..web.templates_admin import _admin_secret_panel, _render_admin_page
ADMIN_TABLE_LIMIT = 100
ADMIN_JSON_OFFSET_CAP = 10_000
def make_router(admin_path: str = "/admin") -> APIRouter:
router = APIRouter()
records_path = f"{admin_path}/records"
events_path = f"{admin_path}/events"
def render_admin(
service: ServiceDep,
settings: SettingsDep,
store: StoreDep,
table_offsets: dict[str, int] | None = None,
notice: str | None = None,
) -> str:
table_offsets = table_offsets or {}
return _render_admin_page(
service,
settings,
store.list_accounts(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("accounts", 0)),
store.list_cleanup_runs(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("cleanup_runs", 0)),
store.list_scheduled_tasks(),
store.list_update_events(ADMIN_TABLE_LIMIT, offset=table_offsets.get("update_events", 0)),
store.get_service_metrics(),
{
"accounts": store.count_accounts(),
"cleanup_runs": store.count_cleanup_runs(),
"update_events": store.count_update_events(),
},
table_offsets,
ADMIN_TABLE_LIMIT,
notice,
)
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
def admin(
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
accounts_offset: int = Query(0, ge=0),
cleanup_runs_offset: int = Query(0, ge=0),
update_events_offset: int = Query(0, ge=0),
) -> str:
return render_admin(
service,
settings,
store,
{
"accounts": accounts_offset,
"cleanup_runs": cleanup_runs_offset,
"update_events": update_events_offset,
},
)
@router.post(f"{admin_path}/accounts/delete", include_in_schema=False)
async def admin_delete_account(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> 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}/accounts/rotate", include_in_schema=False)
async def admin_rotate_account(
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> 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))]) 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,
"management URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.magic_management_url(rotated))]) if rotated else None
elif action == "password":
rotated = await run_in_threadpool(store.rotate_password, hostname)
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
ADMIN_ROUTER_CREDENTIALS_ROTATED,
"router credentials rotated by admin",
client_ip(request, settings),
)
if rotated:
notice = _admin_secret_panel(
"Router password changed",
[
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
_copy_row("Full update URL:", service.credentialed_update_url(rotated), "Secret"),
_copy_row("Benutzername (optional):", str(rotated["username"]), "Public"),
_copy_row("Kennwort (optional):", str(rotated["password"]), "Secret"),
],
)
else:
raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, 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,
service: ServiceDep,
_: None = Depends(authenticate_admin),
) -> 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,
)
notice = _admin_secret_panel(
"Cleanup done",
[
_copy_row("Unused hostnames removed", str(result["unused_accounts"])),
_copy_row("Old update events removed", str(result["update_events"])),
],
)
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
return HTMLResponse(page)
@router.get(records_path, include_in_schema=False)
def records(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
) -> dict[str, object]:
return {
"items": store.list_records_page(limit=limit, offset=offset),
"limit": limit,
"offset": offset,
"total": store.count_records(),
}
@router.get(events_path, include_in_schema=False)
def events(
store: StoreDep,
_: None = Depends(authenticate_admin),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0, le=ADMIN_JSON_OFFSET_CAP),
before: str | None = Query(None, max_length=64),
) -> dict[str, object]:
items = store.list_update_events_before(limit=limit, before=before) if before else store.list_update_events_page(limit=limit, offset=offset)
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
return {
"items": items,
"limit": limit,
"offset": offset,
"next_before": next_before,
"total": store.count_update_events(),
}
if admin_path == "/admin":
router.add_api_route("/records", records, methods=["GET"], include_in_schema=False)
router.add_api_route("/events", events, methods=["GET"], include_in_schema=False)
return router
router = make_router()