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

252 lines
7.3 KiB
Python

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}