Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
@@ -0,0 +1,235 @@
|
||||
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
|
||||
|
||||
|
||||
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)),
|
||||
{
|
||||
"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 rotated", [_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("Private status page rotated", [_copy_row("Private status page:", 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 rotated",
|
||||
[
|
||||
_copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
|
||||
_copy_row("One-box 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 completed",
|
||||
[
|
||||
_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),
|
||||
) -> 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),
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"items": store.list_update_events_page(limit=limit, offset=offset),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"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()
|
||||
Reference in new issue
Block a user