from __future__ import annotations import json from urllib.parse import parse_qs from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import PlainTextResponse from starlette.concurrency import run_in_threadpool from ..dependencies import ServiceDep, StoreDep, apply_update_response from ..dns import DnsProviderError from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED from ..security import client_ip from ..services.ddns import BadIpError from .path_params import ManagementSlugPath, UpdateSlugPath router = APIRouter() @router.get( "/api/v1/router/register/{dashboard_secret}", response_class=PlainTextResponse, response_model=None, operation_id="registerHostnameFromRouterGet", tags=["router"], ) async def register_hostname_get( dashboard_secret: ManagementSlugPath, request: Request, store: StoreDep, service: ServiceDep, myip: str | None = Query(default=None), ipaddr: str | None = Query(default=None), myipv6: str | None = Query(default=None), ip6addr: str | None = Query(default=None), ) -> str | PlainTextResponse: return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr) @router.post( "/api/v1/router/register/{dashboard_secret}", response_class=PlainTextResponse, response_model=None, operation_id="registerHostnameFromRouterPost", tags=["router"], ) async def register_hostname_post( dashboard_secret: ManagementSlugPath, request: Request, store: StoreDep, service: ServiceDep, myip: str | None = Query(default=None), ipaddr: str | None = Query(default=None), myipv6: str | None = Query(default=None), ip6addr: str | None = Query(default=None), ) -> str | PlainTextResponse: return await _register_hostname(dashboard_secret, request, store, service, myip, ipaddr, myipv6, ip6addr) async def _register_hostname( dashboard_secret: str, request: Request, store: StoreDep, service: ServiceDep, myip: str | None, ipaddr: str | None, myipv6: str | None, ip6addr: str | None, ) -> str | PlainTextResponse: myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr) account = await run_in_threadpool(store.get_account_by_management_slug, dashboard_secret) if not account or account["disabled"]: raise HTTPException(status_code=404, detail="not found") hostname = str(account["hostname"]) if not service.hostname_is_managed(hostname): raise HTTPException(status_code=404, detail="not found") if not account["accepted_at"]: try: await run_in_threadpool(service.publish_initial_dns, request, hostname, myip, ipaddr, myipv6, ip6addr) except BadIpError as exc: return PlainTextResponse(f"badip {exc}", status_code=400) except DnsProviderError: return PlainTextResponse("911 dns registration failed", status_code=500) activated = await run_in_threadpool(store.activate_account, hostname) if not activated: raise HTTPException(status_code=404, detail="not found") await run_in_threadpool( store.log_update_event, hostname, None, None, DASHBOARD_HOSTNAME_ACTIVATED, "hostname registered", client_ip(request, service.settings), ) return "good registered" @router.get( "/api/v1/router/update/{update_slug}", response_class=PlainTextResponse, response_model=None, operation_id="updateHostnameFromRouterGet", tags=["router"], ) async def update_hostname_get( update_slug: UpdateSlugPath, request: Request, store: StoreDep, service: ServiceDep, myip: str | None = Query(default=None), ipaddr: str | None = Query(default=None), myipv6: str | None = Query(default=None), ip6addr: str | None = Query(default=None), ) -> str | PlainTextResponse: return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr) @router.post( "/api/v1/router/update/{update_slug}", response_class=PlainTextResponse, response_model=None, operation_id="updateHostnameFromRouterPost", tags=["router"], ) async def update_hostname_post( update_slug: UpdateSlugPath, request: Request, store: StoreDep, service: ServiceDep, myip: str | None = Query(default=None), ipaddr: str | None = Query(default=None), myipv6: str | None = Query(default=None), ip6addr: str | None = Query(default=None), ) -> str | PlainTextResponse: return await _update_hostname(update_slug, request, store, service, myip, ipaddr, myipv6, ip6addr) async def _update_hostname( update_slug: str, request: Request, store: StoreDep, service: ServiceDep, myip: str | None, ipaddr: str | None, myipv6: str | None, ip6addr: str | None, ) -> str | PlainTextResponse: myip, ipaddr, myipv6, ip6addr = await _ip_params_from_request(request, myip, ipaddr, myipv6, ip6addr) account = await run_in_threadpool(store.get_account_by_slug, update_slug) if not account or account["disabled"] or not account["accepted_at"]: raise HTTPException(status_code=404, detail="not found") if not service.hostname_is_managed(str(account["hostname"])): raise HTTPException(status_code=404, detail="not found") return await run_in_threadpool(apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr) async def _ip_params_from_request( request: Request, myip: str | None, ipaddr: str | None, myipv6: str | None, ip6addr: str | None, ) -> tuple[str | None, str | None, str | None, str | None]: if request.method != "POST": return myip, ipaddr, myipv6, ip6addr body_values = await _post_values(request) return ( myip or body_values.get("myip"), ipaddr or body_values.get("ipaddr"), myipv6 or body_values.get("myipv6"), ip6addr or body_values.get("ip6addr"), ) async def _post_values(request: Request) -> dict[str, str]: content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower() body = await request.body() if not body: return {} if content_type == "application/json": try: data = json.loads(body) except json.JSONDecodeError: return {} if not isinstance(data, dict): return {} return {str(key): str(value) for key, value in data.items() if isinstance(value, str)} if content_type == "application/x-www-form-urlencoded": try: form_body = body.decode("utf-8", errors="strict") except UnicodeDecodeError: return {} try: parsed = parse_qs(form_body, keep_blank_values=True, max_num_fields=8) except ValueError: return {} return {key: values[0] for key, values in parsed.items() if values} return {}