Archived
179 lines
6.5 KiB
Python
179 lines
6.5 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import sqlite3
|
||
|
|
|
||
|
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||
|
|
from starlette.concurrency import run_in_threadpool
|
||
|
|
|
||
|
|
from ..ddns_dns import DnsProviderError
|
||
|
|
from ..ddns_service import BadIpError, normalize_domain
|
||
|
|
from ..dependencies import ServiceDep, StoreDep
|
||
|
|
from ..schemas import (
|
||
|
|
CustomHostnameRequest,
|
||
|
|
DomainChallengeRequest,
|
||
|
|
DomainChallengeResponse,
|
||
|
|
ErrorResponse,
|
||
|
|
HostnameCredentials,
|
||
|
|
MagicHostnameRequest,
|
||
|
|
ManagedRecord,
|
||
|
|
RouterUpdateResponse,
|
||
|
|
VerifyDomainRequest,
|
||
|
|
VerifyDomainResponse,
|
||
|
|
_credentials_response,
|
||
|
|
_managed_record,
|
||
|
|
)
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/api/v1")
|
||
|
|
|
||
|
|
|
||
|
|
@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, service: ServiceDep) -> HostnameCredentials:
|
||
|
|
account = await run_in_threadpool(service.create_managed_account, payload.username)
|
||
|
|
return _credentials_response(service, account)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(
|
||
|
|
"/management/{management_slug}",
|
||
|
|
response_model=ManagedRecord,
|
||
|
|
summary="Read a hostname by magic management link",
|
||
|
|
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
|
||
|
|
tags=["management"],
|
||
|
|
responses={404: {"model": ErrorResponse}},
|
||
|
|
)
|
||
|
|
async def read_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> 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",
|
||
|
|
description="Uses the secret bearer management slug. Dashboard passwords protect only the web dashboard and are not required for this JSON API route.",
|
||
|
|
tags=["management"],
|
||
|
|
responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}},
|
||
|
|
)
|
||
|
|
async def delete_managed_hostname(management_slug: str, store: StoreDep, service: ServiceDep) -> 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 DnsProviderError 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, service: ServiceDep) -> DomainChallengeResponse:
|
||
|
|
domain = normalize_domain(payload.domain)
|
||
|
|
try:
|
||
|
|
challenge = await run_in_threadpool(service.create_domain_challenge, domain)
|
||
|
|
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, service: ServiceDep) -> VerifyDomainResponse:
|
||
|
|
domain = normalize_domain(payload.domain)
|
||
|
|
found, _challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret)
|
||
|
|
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, service: ServiceDep) -> HostnameCredentials:
|
||
|
|
account = await run_in_threadpool(
|
||
|
|
service.create_custom_account,
|
||
|
|
payload.hostname,
|
||
|
|
payload.claim_secret,
|
||
|
|
payload.username,
|
||
|
|
)
|
||
|
|
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,
|
||
|
|
store: StoreDep,
|
||
|
|
service: ServiceDep,
|
||
|
|
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 BadIpError as exc:
|
||
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
|
|
except DnsProviderError 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 "")
|