2026-05-20 23:55:27 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-17 23:17:31 +02:00
|
|
|
from typing import Annotated
|
2026-05-21 22:26:14 +02:00
|
|
|
from urllib.parse import urlencode
|
|
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
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
|
2026-05-21 17:23:08 +02:00
|
|
|
from ..dns import DnsProviderError
|
2026-05-20 23:55:27 +02:00
|
|
|
from ..event_statuses import (
|
|
|
|
|
ADMIN_HOSTNAME_DELETED,
|
|
|
|
|
ADMIN_MANAGEMENT_URL_ROTATED,
|
|
|
|
|
ADMIN_UPDATE_URL_ROTATED,
|
|
|
|
|
)
|
2026-05-21 17:23:08 +02:00
|
|
|
from ..security import client_ip, require_admin_csrf
|
|
|
|
|
from ..services.ddns import normalize_hostname
|
|
|
|
|
from ..web.components import _copy_row
|
2026-05-20 23:55:27 +02:00
|
|
|
from ..web.forms import first_form_value, request_form
|
2026-05-21 22:26:14 +02:00
|
|
|
from ..web.html_utils import SafeHtml
|
2026-06-17 23:17:31 +02:00
|
|
|
from ..web.templates_admin import _admin_notice, _admin_page_context, _admin_secret_panel, _render_admin_page
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
ADMIN_TABLE_LIMIT = 100
|
2026-05-21 10:05:40 +02:00
|
|
|
ADMIN_JSON_OFFSET_CAP = 10_000
|
2026-06-17 23:17:31 +02:00
|
|
|
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)]
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_router(admin_path: str = "/admin") -> APIRouter:
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
def render_admin(
|
|
|
|
|
settings: SettingsDep,
|
|
|
|
|
store: StoreDep,
|
|
|
|
|
table_offsets: dict[str, int] | None = None,
|
2026-05-21 22:26:14 +02:00
|
|
|
notice: SafeHtml | None = None,
|
2026-05-20 23:55:27 +02:00
|
|
|
) -> str:
|
|
|
|
|
table_offsets = table_offsets or {}
|
2026-06-17 23:17:31 +02:00
|
|
|
context = _admin_page_context(store, table_offsets, ADMIN_TABLE_LIMIT, notice)
|
|
|
|
|
return _render_admin_page(settings, context)
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
@router.get(admin_path, response_class=HTMLResponse, include_in_schema=False)
|
|
|
|
|
def admin(
|
|
|
|
|
settings: SettingsDep,
|
|
|
|
|
store: StoreDep,
|
2026-06-17 23:17:31 +02:00
|
|
|
_: AdminAuthDep,
|
|
|
|
|
table_offsets: AdminTableOffsetsDep,
|
|
|
|
|
notice: AdminCleanupNoticeDep,
|
2026-05-20 23:55:27 +02:00
|
|
|
) -> str:
|
2026-06-17 23:17:31 +02:00
|
|
|
return render_admin(settings, store, table_offsets, notice)
|
2026-05-20 23:55:27 +02:00
|
|
|
|
2026-05-21 17:23:08 +02:00
|
|
|
@router.post(f"{admin_path}/hostnames/delete", include_in_schema=False)
|
2026-05-20 23:55:27 +02:00
|
|
|
async def admin_delete_account(
|
|
|
|
|
request: Request,
|
|
|
|
|
settings: SettingsDep,
|
|
|
|
|
store: StoreDep,
|
|
|
|
|
service: ServiceDep,
|
2026-06-17 23:17:31 +02:00
|
|
|
_: AdminAuthDep,
|
2026-05-20 23:55:27 +02:00
|
|
|
) -> 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)
|
|
|
|
|
|
2026-05-21 17:23:08 +02:00
|
|
|
@router.post(f"{admin_path}/hostnames/rotate", include_in_schema=False)
|
2026-05-20 23:55:27 +02:00
|
|
|
async def admin_rotate_account(
|
|
|
|
|
request: Request,
|
|
|
|
|
settings: SettingsDep,
|
|
|
|
|
store: StoreDep,
|
|
|
|
|
service: ServiceDep,
|
2026-06-17 23:17:31 +02:00
|
|
|
_: AdminAuthDep,
|
2026-05-20 23:55:27 +02:00
|
|
|
) -> 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),
|
|
|
|
|
)
|
2026-05-22 17:05:06 +02:00
|
|
|
notice = (
|
|
|
|
|
_admin_secret_panel(
|
|
|
|
|
"Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated), "Secret")]
|
|
|
|
|
)
|
|
|
|
|
if rotated
|
|
|
|
|
else None
|
|
|
|
|
)
|
2026-05-20 23:55:27 +02:00
|
|
|
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,
|
2026-05-21 17:23:08 +02:00
|
|
|
"dashboard link rotated by admin",
|
2026-05-20 23:55:27 +02:00
|
|
|
client_ip(request, settings),
|
|
|
|
|
)
|
2026-05-22 17:05:06 +02:00
|
|
|
notice = (
|
|
|
|
|
_admin_secret_panel(
|
|
|
|
|
"Dashboard link changed",
|
|
|
|
|
[_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")],
|
|
|
|
|
)
|
|
|
|
|
if rotated
|
|
|
|
|
else None
|
|
|
|
|
)
|
2026-05-20 23:55:27 +02:00
|
|
|
else:
|
|
|
|
|
raise HTTPException(status_code=400, detail="invalid rotation action")
|
2026-06-17 23:17:31 +02:00
|
|
|
page = await run_in_threadpool(render_admin, settings, store, None, notice)
|
2026-05-20 23:55:27 +02:00
|
|
|
return HTMLResponse(page)
|
|
|
|
|
|
|
|
|
|
@router.post(f"{admin_path}/cleanup", include_in_schema=False)
|
|
|
|
|
async def admin_run_cleanup(
|
|
|
|
|
request: Request,
|
|
|
|
|
settings: SettingsDep,
|
|
|
|
|
store: StoreDep,
|
2026-06-17 23:17:31 +02:00
|
|
|
_: AdminAuthDep,
|
2026-05-20 23:55:27 +02:00
|
|
|
) -> 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,
|
2026-05-21 17:23:08 +02:00
|
|
|
settings.cleanup_pending_account_hours,
|
2026-05-20 23:55:27 +02:00
|
|
|
)
|
2026-05-21 22:26:14 +02:00
|
|
|
redirect_query = urlencode(
|
|
|
|
|
{
|
|
|
|
|
"cleanup_done": "1",
|
|
|
|
|
"cleanup_unused": result["unused_accounts"],
|
|
|
|
|
"cleanup_events": result["update_events"],
|
|
|
|
|
}
|
2026-05-20 23:55:27 +02:00
|
|
|
)
|
2026-05-21 22:26:14 +02:00
|
|
|
return RedirectResponse(f"{admin_path}?{redirect_query}", status_code=303)
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
return router
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
router = make_router()
|