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

267 lines
9.9 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
from __future__ import annotations
from typing import Annotated, NoReturn
2026-05-20 23:55:27 +02:00
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
2026-05-21 17:23:08 +02:00
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
2026-05-20 23:55:27 +02:00
from starlette.concurrency import run_in_threadpool
from ..dependencies import ServiceDep, SettingsDep, StoreDep
2026-05-21 17:23:08 +02:00
from ..dns import DnsProviderError
2026-05-20 23:55:27 +02:00
from ..event_statuses import (
2026-05-21 17:23:08 +02:00
DASHBOARD_HOSTNAME_ACTIVATED,
2026-05-20 23:55:27 +02:00
DASHBOARD_HOSTNAME_DELETED,
DASHBOARD_UPDATE_URL_ROTATED,
)
2026-05-21 17:23:08 +02:00
from ..security import client_ip, dashboard_csrf_token, require_dashboard_csrf
from ..services.ddns import BadIpError, UpdateIpParams
2026-05-20 23:55:27 +02:00
from ..web.forms import first_form_value, request_form
from ..web.html_utils import trusted
2026-05-21 17:23:08 +02:00
from ..web.layout import _page
from ..web.templates_management import (
ManagementPageContext,
PendingManagementPageContext,
2026-05-21 17:23:08 +02:00
_render_management_page,
_render_management_unlock_page,
_render_pending_management_page,
)
2026-05-21 14:18:39 +02:00
from . import management_passwords
2026-05-20 23:55:27 +02:00
from .management_shared import (
get_active_management_account,
get_active_management_account_async,
has_management_access,
locked_management_response,
2026-05-21 17:23:08 +02:00
management_account_is_accepted,
2026-05-20 23:55:27 +02:00
render_management_page_async,
)
2026-05-21 10:05:40 +02:00
from .path_params import ManagementSlugPath
2026-05-20 23:55:27 +02:00
router = APIRouter()
router.include_router(management_passwords.router)
ActivationIpQuery = Annotated[str | None, Query()]
def _activation_ip_params_from_query(
myip: ActivationIpQuery = None,
ipaddr: ActivationIpQuery = None,
myipv6: ActivationIpQuery = None,
ip6addr: ActivationIpQuery = None,
) -> UpdateIpParams:
return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr)
ActivationIpParamsDep = Annotated[UpdateIpParams, Depends(_activation_ip_params_from_query)]
2026-05-20 23:55:27 +02:00
@router.get("/m/{management_slug}", response_class=HTMLResponse, include_in_schema=False)
2026-05-21 17:23:08 +02:00
def manage_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
service: ServiceDep,
activated: bool = False,
) -> str:
account = get_active_management_account(service.store, management_slug)
2026-05-20 23:55:27 +02:00
csrf_token = dashboard_csrf_token(management_slug, account)
2026-05-21 17:23:08 +02:00
if not management_account_is_accepted(account):
2026-05-22 17:05:06 +02:00
return _render_pending_management_page(
service,
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=csrf_token,
request_ip=client_ip(request, settings),
),
2026-05-22 17:05:06 +02:00
)
2026-05-21 10:05:40 +02:00
if not has_management_access(request, settings, management_slug, account):
2026-05-20 23:55:27 +02:00
return _render_management_unlock_page(management_slug, account, csrf_token)
2026-05-21 17:23:08 +02:00
message = "Hostname registered. Add the update URL to your router." if activated else None
2026-05-22 17:05:06 +02:00
return _render_management_page(
service,
ManagementPageContext(
management_slug=management_slug,
account=account,
csrf_token=csrf_token,
message=message,
request_ip=client_ip(request, settings),
),
2026-05-22 17:05:06 +02:00
)
2026-05-21 17:23:08 +02:00
2026-05-22 17:05:06 +02:00
@router.post("/m/{management_slug}/activate", include_in_schema=False, response_model=None)
2026-05-21 17:23:08 +02:00
async def activate_dashboard(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
service: ServiceDep,
ip_params: ActivationIpParamsDep,
2026-05-22 17:05:06 +02:00
) -> RedirectResponse | HTMLResponse:
account = await get_active_management_account_async(service.store, management_slug)
2026-05-21 17:23:08 +02:00
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
if not management_account_is_accepted(account):
try:
await run_in_threadpool(
service.publish_initial_dns,
request,
hostname,
ip_params,
)
2026-05-21 17:23:08 +02:00
except BadIpError:
return HTMLResponse(
_render_pending_management_page(
2026-05-21 17:58:20 +02:00
service,
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message=(
"The supplied IP address is invalid. Leave IP fields empty and let the router "
"send the first update."
),
request_ip=client_ip(request, settings),
),
2026-05-21 17:23:08 +02:00
),
status_code=400,
)
except DnsProviderError:
return HTMLResponse(
_render_pending_management_page(
2026-05-21 17:58:20 +02:00
service,
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message="DNS registration failed. The hostname was not registered.",
request_ip=client_ip(request, settings),
),
2026-05-21 17:23:08 +02:00
),
status_code=500,
)
activated_account = await run_in_threadpool(lambda: service.store.activate_account(hostname))
2026-05-22 17:05:06 +02:00
if not activated_account:
2026-05-21 17:23:08 +02:00
return _not_found()
await run_in_threadpool(
service.store.log_update_event,
2026-05-21 17:23:08 +02:00
hostname,
None,
None,
DASHBOARD_HOSTNAME_ACTIVATED,
"hostname registered",
client_ip(request, settings),
)
return RedirectResponse(f"/m/{management_slug}?activated=1", status_code=303)
2026-05-20 23:55:27 +02:00
2026-05-22 17:05:06 +02:00
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False, response_model=None)
2026-05-21 17:23:08 +02:00
async def rotate_dashboard_secrets(
2026-05-21 10:05:40 +02:00
management_slug: ManagementSlugPath,
2026-05-20 23:55:27 +02:00
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
2026-05-22 17:05:06 +02:00
) -> str | HTMLResponse:
2026-05-20 23:55:27 +02:00
account = await get_active_management_account_async(store, management_slug)
2026-05-21 17:23:08 +02:00
if not management_account_is_accepted(account):
return HTMLResponse(
2026-05-21 17:58:20 +02:00
_render_pending_management_page(
service,
PendingManagementPageContext(
settings=settings,
management_slug=management_slug,
account=account,
csrf_token=dashboard_csrf_token(management_slug, account),
message="Register this hostname first.",
request_ip=client_ip(request, settings),
),
2026-05-21 17:58:20 +02:00
),
2026-05-21 17:23:08 +02:00
status_code=400,
)
2026-05-21 10:05:40 +02:00
if not has_management_access(request, settings, management_slug, account):
2026-05-20 23:55:27 +02:00
return locked_management_response(management_slug, account)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
action = first_form_value(form, "action")
2026-05-21 17:23:08 +02:00
request_ip = client_ip(request, settings)
2026-05-20 23:55:27 +02:00
if action == "update":
rotated = await run_in_threadpool(store.rotate_update_slug, hostname)
if not rotated:
return _not_found()
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_UPDATE_URL_ROTATED,
"router update URL rotated",
2026-05-21 17:23:08 +02:00
request_ip,
2026-05-20 23:55:27 +02:00
)
return await render_management_page_async(
service,
ManagementPageContext(
management_slug=management_slug,
account=rotated,
csrf_token=dashboard_csrf_token(management_slug, rotated),
message="Update URL changed. Update your router now.",
request_ip=request_ip,
),
2026-05-20 23:55:27 +02:00
)
raise _bad_rotation_action()
@router.post("/m/{management_slug}/delete", include_in_schema=False)
2026-05-22 17:05:06 +02:00
async def delete_dashboard_hostname(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep
) -> Response:
2026-05-20 23:55:27 +02:00
account = await get_active_management_account_async(store, management_slug)
2026-05-21 10:05:40 +02:00
if not has_management_access(request, settings, management_slug, account):
2026-05-22 17:05:06 +02:00
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
2026-05-20 23:55:27 +02:00
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "management-link")
except DnsProviderError:
2026-05-21 10:05:40 +02:00
return PlainTextResponse("DNS delete failed. Hostname was not deleted.", status_code=500)
2026-05-20 23:55:27 +02:00
await run_in_threadpool(
store.log_update_event,
hostname,
None,
None,
DASHBOARD_HOSTNAME_DELETED,
"hostname deleted from dashboard",
client_ip(request, settings),
)
return HTMLResponse(
_page(
2026-05-21 10:05:40 +02:00
"Hostname deleted",
2026-05-22 17:05:06 +02:00
trusted("""
2026-05-20 23:55:27 +02:00
<main>
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
<p class="eyebrow">Deleted</p>
<h1>Hostname deleted.</h1>
<p class="lead">The update URL no longer works.</p>
<a class="btn btn-outline-primary rounded-pill" href="/">Home</a>
</div>
</section>
</main>
2026-05-22 17:05:06 +02:00
"""),
2026-05-20 23:55:27 +02:00
)
)
def _not_found() -> NoReturn:
raise HTTPException(status_code=404, detail="not found")
def _bad_rotation_action() -> NoReturn:
raise HTTPException(status_code=400, detail="invalid rotation action")