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
karldns.de/router_dyndns/ddns_api.py
T

255 lines
9.0 KiB
Python

import sqlite3
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from .ddns_models import DdnsSettings
from .ddns_security import verify_session_cookie
from .ddns_service import DdnsService, normalize_domain
from .ddns_store import DdnsStore
class ErrorResponse(BaseModel):
detail: str = Field(examples=["not found"])
class MagicHostnameRequest(BaseModel):
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class HostnameCredentials(BaseModel):
hostname: str
username: str
password: str
update_url: str
management_url: str
class ManagedRecord(BaseModel):
hostname: str
username: str
update_url: str
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
class DomainChallengeRequest(BaseModel):
domain: str = Field(examples=["example.net"])
class DomainChallengeResponse(BaseModel):
domain: str
txt_name: str
txt_value: str
claim_secret: str
class VerifyDomainRequest(BaseModel):
domain: str = Field(examples=["example.net"])
claim_secret: str
class VerifyDomainResponse(BaseModel):
domain: str
verified: bool
message: str
claim_secret: str
class CustomHostnameRequest(BaseModel):
hostname: str = Field(examples=["home.example.net"])
claim_secret: str
username: str | None = Field(default=None, max_length=128, examples=["home-router"])
class RouterUpdateResponse(BaseModel):
status: str = Field(examples=["good"])
hostname: str
ip: str
def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsService) -> APIRouter:
router = APIRouter(prefix="/api/v1")
def optional_user(request: Request) -> dict[str, str | int] | None:
user_id = verify_session_cookie(request.cookies.get("ddns_session"), settings.session_secret)
return store.get_user(user_id) if user_id is not None else None
optional_user_dependency = Depends(optional_user)
@router.post(
"/hostnames/magic",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate a provider-owned DynDNS hostname",
tags=["hostnames"],
responses={500: {"model": ErrorResponse}},
)
async def create_magic_hostname(payload: MagicHostnameRequest) -> HostnameCredentials:
account = await run_in_threadpool(service.create_managed_account, payload.username, None)
return _credentials_response(service, account)
@router.get(
"/management/{management_slug}",
response_model=ManagedRecord,
summary="Read a hostname by magic management link",
tags=["management"],
responses={404: {"model": ErrorResponse}},
)
async def read_managed_hostname(management_slug: str) -> ManagedRecord:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
return _managed_record(service, account)
@router.delete(
"/management/{management_slug}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a hostname by magic management link",
tags=["management"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def delete_managed_hostname(management_slug: str) -> Response:
account = await run_in_threadpool(store.get_account_by_management_slug, management_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
await run_in_threadpool(service.delete_account, hostname, "api")
except Exception as exc:
raise HTTPException(status_code=500, detail="DNS delete failed; hostname was not deleted") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/domains/challenges",
response_model=DomainChallengeResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a DNS TXT domain verification challenge",
tags=["domains"],
responses={400: {"model": ErrorResponse}},
)
async def create_domain_challenge(
payload: DomainChallengeRequest,
user: dict[str, str | int] | None = optional_user_dependency,
) -> DomainChallengeResponse:
domain = normalize_domain(payload.domain)
try:
challenge = await run_in_threadpool(service.create_domain_challenge, domain, int(user["id"]) if user else None)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
return DomainChallengeResponse(
domain=domain,
txt_name=service.verification_name(domain),
txt_value=challenge["token"],
claim_secret=challenge["claim_secret"],
)
@router.post(
"/domains/verify",
response_model=VerifyDomainResponse,
summary="Verify a domain TXT challenge",
tags=["domains"],
responses={404: {"model": ErrorResponse}},
)
async def verify_domain(
payload: VerifyDomainRequest,
user: dict[str, str | int] | None = optional_user_dependency,
) -> VerifyDomainResponse:
domain = normalize_domain(payload.domain)
found, challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret, int(user["id"]) if user else None)
if not found:
return VerifyDomainResponse(
domain=domain,
verified=False,
message="TXT record not found yet",
claim_secret=payload.claim_secret,
)
return VerifyDomainResponse(
domain=domain,
verified=True,
message="domain verified",
claim_secret=payload.claim_secret,
)
@router.post(
"/hostnames/custom",
response_model=HostnameCredentials,
status_code=status.HTTP_201_CREATED,
summary="Generate credentials for a verified custom hostname",
tags=["hostnames"],
responses={400: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def create_custom_hostname(
payload: CustomHostnameRequest,
user: dict[str, str | int] | None = optional_user_dependency,
) -> HostnameCredentials:
account = await run_in_threadpool(
service.create_custom_account,
payload.hostname,
payload.claim_secret,
payload.username,
int(user["id"]) if user else None,
)
return _credentials_response(service, account)
@router.get(
"/updates/{update_slug}",
response_model=RouterUpdateResponse,
summary="Router-compatible update endpoint",
tags=["updates"],
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
)
async def update_hostname(
update_slug: str,
request: Request,
myip: str | None = None,
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RouterUpdateResponse:
account = await run_in_threadpool(store.get_account_by_slug, update_slug)
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
hostname = str(account["hostname"])
try:
outcome = await run_in_threadpool(
service.apply_update,
request,
hostname,
myip,
ipaddr,
myipv6,
ip6addr,
)
except Exception as exc:
raise HTTPException(status_code=500, detail="DNS publish failed") from exc
result = outcome.result
return RouterUpdateResponse(status="good" if result.changed else "nochg", hostname=hostname, ip=result.ipv4 or result.ipv6 or "")
return router
def _credentials_response(service: DdnsService, account: dict[str, str]) -> HostnameCredentials:
return HostnameCredentials(
hostname=account["hostname"],
username=account["username"],
password=account["password"],
update_url=service.fritz_update_url(account),
management_url=service.magic_management_url(account),
)
def _managed_record(service: DdnsService, account: dict[str, str | int | None]) -> ManagedRecord:
account_for_url = {"update_slug": str(account["update_slug"])}
return ManagedRecord(
hostname=str(account["hostname"]),
username=str(account["username"]),
update_url=service.fritz_update_url(account_for_url),
ipv4=str(account["ipv4"]) if account.get("ipv4") else None,
ipv6=str(account["ipv6"]) if account.get("ipv6") else None,
updated_at=str(account["updated_at"]) if account.get("updated_at") else None,
)