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_domain_challenges(limit=ADMIN_TABLE_LIMIT, offset=table_offsets.get("domain_challenges", 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(), "domain_challenges": store.count_domain_challenges(), "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), domain_claims_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, "domain_challenges": domain_claims_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_challenge_hours, settings.cleanup_unused_account_hours, settings.cleanup_event_history_hours, ) notice = _admin_secret_panel( "Cleanup done", [ _copy_row("Domain claims removed", str(result["domain_challenges"])), _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()