2026-05-20 23:55:27 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import NoReturn
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, 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
|
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 (
|
|
|
|
|
_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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
|
|
|
|
store: StoreDep,
|
|
|
|
|
service: ServiceDep,
|
|
|
|
|
activated: bool = False,
|
|
|
|
|
) -> str:
|
2026-05-20 23:55:27 +02:00
|
|
|
account = get_active_management_account(store, management_slug)
|
|
|
|
|
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-21 17:58:20 +02:00
|
|
|
return _render_pending_management_page(service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings))
|
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
|
|
|
|
|
return _render_management_page(service, settings, management_slug, account, csrf_token, message=message, request_ip=client_ip(request, settings))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/m/{management_slug}/activate", include_in_schema=False)
|
|
|
|
|
async def activate_dashboard(
|
|
|
|
|
management_slug: ManagementSlugPath,
|
|
|
|
|
request: Request,
|
|
|
|
|
settings: SettingsDep,
|
|
|
|
|
store: StoreDep,
|
|
|
|
|
service: ServiceDep,
|
|
|
|
|
myip: str | None = None,
|
|
|
|
|
ipaddr: str | None = None,
|
|
|
|
|
myipv6: str | None = None,
|
|
|
|
|
ip6addr: str | None = None,
|
|
|
|
|
) -> RedirectResponse:
|
|
|
|
|
account = await get_active_management_account_async(store, management_slug)
|
|
|
|
|
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, myip, ipaddr, myipv6, ip6addr)
|
|
|
|
|
except BadIpError:
|
|
|
|
|
return HTMLResponse(
|
|
|
|
|
_render_pending_management_page(
|
2026-05-21 17:58:20 +02:00
|
|
|
service,
|
2026-05-21 17:23:08 +02:00
|
|
|
settings,
|
|
|
|
|
management_slug,
|
|
|
|
|
account,
|
|
|
|
|
dashboard_csrf_token(management_slug, account),
|
|
|
|
|
"Registration needs a public source IP. Try again from your home connection or use the API with myip.",
|
2026-05-21 17:58:20 +02:00
|
|
|
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,
|
2026-05-21 17:23:08 +02:00
|
|
|
settings,
|
|
|
|
|
management_slug,
|
|
|
|
|
account,
|
|
|
|
|
dashboard_csrf_token(management_slug, account),
|
|
|
|
|
"DNS registration failed. The hostname was not registered.",
|
2026-05-21 17:58:20 +02:00
|
|
|
client_ip(request, settings),
|
2026-05-21 17:23:08 +02:00
|
|
|
),
|
|
|
|
|
status_code=500,
|
|
|
|
|
)
|
|
|
|
|
account = await run_in_threadpool(store.activate_account, hostname)
|
|
|
|
|
if not account:
|
|
|
|
|
return _not_found()
|
|
|
|
|
await run_in_threadpool(
|
|
|
|
|
store.log_update_event,
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
|
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,
|
|
|
|
|
) -> str:
|
|
|
|
|
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,
|
|
|
|
|
settings,
|
|
|
|
|
management_slug,
|
|
|
|
|
account,
|
|
|
|
|
dashboard_csrf_token(management_slug, account),
|
|
|
|
|
"Register this hostname first.",
|
|
|
|
|
client_ip(request, settings),
|
|
|
|
|
),
|
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,
|
|
|
|
|
settings,
|
|
|
|
|
management_slug,
|
|
|
|
|
rotated,
|
|
|
|
|
dashboard_csrf_token(management_slug, rotated),
|
2026-05-21 10:05:40 +02:00
|
|
|
message="Update URL changed. Update your router now.",
|
2026-05-21 17:23:08 +02:00
|
|
|
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-21 17:23:08 +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-20 23:55:27 +02:00
|
|
|
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
|
|
|
|
|
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-20 23:55:27 +02:00
|
|
|
trusted(
|
|
|
|
|
"""
|
|
|
|
|
<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>
|
|
|
|
|
"""
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|