Archived
252 lines
7.3 KiB
Python
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}
|