from __future__ import annotations import json from collections.abc import Mapping from typing import Annotated from urllib.parse import parse_qs from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import PlainTextResponse from starlette.concurrency import run_in_threadpool from ..dependencies import ServiceDep, apply_update_response from ..dns import DnsProviderError from ..event_statuses import DASHBOARD_HOSTNAME_ACTIVATED from ..security import client_ip from ..services.ddns import BadIpError, DdnsService, UpdateIpParams from .path_params import ManagementSlugPath, UpdateSlugPath router = APIRouter() RouterIpQuery = Annotated[str | None, Query()] _IpParams = UpdateIpParams def _ip_params_from_query( myip: RouterIpQuery = None, ipaddr: RouterIpQuery = None, myipv6: RouterIpQuery = None, ip6addr: RouterIpQuery = None, ) -> UpdateIpParams: return UpdateIpParams(myip=myip, ipaddr=ipaddr, myipv6=myipv6, ip6addr=ip6addr) RouterIpParamsDep = Annotated[UpdateIpParams, Depends(_ip_params_from_query)] @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, service: ServiceDep, ip_params: RouterIpParamsDep, ) -> str | PlainTextResponse: return await _register_hostname( dashboard_secret, request, service, ip_params, ) @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, service: ServiceDep, ip_params: RouterIpParamsDep, ) -> str | PlainTextResponse: return await _register_hostname( dashboard_secret, request, service, ip_params, ) async def _register_hostname( dashboard_secret: str, request: Request, service: ServiceDep, ip_params: _IpParams, ) -> str | PlainTextResponse: ip_params = await _ip_params_from_request(request, ip_params) account = await run_in_threadpool(service.store.get_account_by_management_slug, dashboard_secret) hostname = _registration_hostname(account, service) assert account is not None dns_error = await _publish_initial_registration_dns(request, service, account, hostname, ip_params) if dns_error is not None: return dns_error activated = await run_in_threadpool(service.store.activate_account, hostname) if not activated: raise HTTPException(status_code=404, detail="not found") await _log_registration_activation(request, service, hostname) return "good registered" def _registration_hostname(account: Mapping[str, object] | None, service: DdnsService) -> str: if not account or account.get("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") return hostname async def _publish_initial_registration_dns( request: Request, service: DdnsService, account: Mapping[str, object], hostname: str, ip_params: _IpParams, ) -> PlainTextResponse | None: if account.get("accepted_at"): return None try: await run_in_threadpool( service.publish_initial_dns, request, hostname, ip_params, ) except BadIpError as exc: return PlainTextResponse(f"badip {exc}", status_code=400) except DnsProviderError: return PlainTextResponse("911 dns registration failed", status_code=500) return None async def _log_registration_activation( request: Request, service: DdnsService, hostname: str, ) -> None: await run_in_threadpool( service.store.log_update_event, hostname, None, None, DASHBOARD_HOSTNAME_ACTIVATED, "hostname registered", client_ip(request, service.settings), ) @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, service: ServiceDep, ip_params: RouterIpParamsDep, ) -> str | PlainTextResponse: return await _update_hostname( update_slug, request, service, ip_params, ) @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, service: ServiceDep, ip_params: RouterIpParamsDep, ) -> str | PlainTextResponse: return await _update_hostname( update_slug, request, service, ip_params, ) async def _update_hostname( update_slug: str, request: Request, service: ServiceDep, ip_params: _IpParams, ) -> str | PlainTextResponse: ip_params = await _ip_params_from_request(request, ip_params) account = await run_in_threadpool(service.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"]), ip_params, ) async def _ip_params_from_request(request: Request, ip_params: _IpParams) -> _IpParams: if request.method != "POST": return ip_params body_values = await _post_values(request) return ip_params.with_body_values(body_values) async def _post_values(request: Request) -> dict[str, str]: body = await request.body() if not body: return {} content_type = _request_content_type(request) if content_type == "application/json": return _json_post_values(body) if content_type == "application/x-www-form-urlencoded": return _form_post_values(body) return {} def _request_content_type(request: Request) -> str: return request.headers.get("content-type", "").split(";", 1)[0].strip().lower() def _json_post_values(body: bytes) -> dict[str, str]: try: data = json.loads(body) except json.JSONDecodeError: return {} if not isinstance(data, dict): return {} return {str(key): value for key, value in data.items() if isinstance(value, str)} def _form_post_values(body: bytes) -> dict[str, str]: 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}