Prepare KarlDNS production deployment

This commit is contained in:
StasonJatham committed 2026-05-21 17:23:08 +02:00
1 parent 5a90bd9d87
commit ce5b4e0ff2
101 files changed
+3364 -2018

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
from pydantic import BaseModel, Field
from .services.ddns import DdnsService
class ErrorResponse(BaseModel):
detail: str = Field(examples=["not found"])
class HostnameCreateResponse(BaseModel):
hostname: str
status: str = Field(description="Lifecycle state. Newly generated hostnames start as pending until registered.")
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
registration_url: str = Field(description="Secret URL used to register the pending hostname after secrets are saved.")
class HostnameStatusResponse(BaseModel):
hostname: str
status: str
update_url: str = Field(description="Secret update URL for router DynDNS updates.")
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
history: list["HostnameUpdateEvent"] = Field(default_factory=list)
class HostnameRotateUpdateResponse(BaseModel):
hostname: str
status: str
update_url: str = Field(description="Secret replacement update URL for router DynDNS updates.")
dashboard_url: str = Field(description="Secret dashboard URL for reading, rotating, or deleting this hostname.")
class HostnameUpdateEvent(BaseModel):
hostname: str
ipv4: str | None = None
ipv6: str | None = None
status: str
detail: str
created_at: str
count: int = 1
class AdminHostname(BaseModel):
hostname: str
status: str
ipv4: str | None = None
ipv6: str | None = None
updated_at: str | None = None
created_at: str | None = None
accepted_at: str | None = None
disabled: bool = False
class AdminRecord(BaseModel):
hostname: str
ipv4: str | None = None
ipv6: str | None = None
updated_at: str
class AdminUpdateEvent(BaseModel):
hostname: str
ipv4: str | None = None
ipv6: str | None = None
status: str
detail: str
source_ip: str
created_at: str
count: int = 1
class AdminCleanupRun(BaseModel):
created_at: str
unused_accounts_deleted: int
class AdminScheduledTask(BaseModel):
name: str
interval_seconds: int
next_run_at: str
lease_owner: str | None = None
lease_expires_at: str | None = None
last_started_at: str | None = None
last_finished_at: str | None = None
last_status: str
last_error: str
updated_at: str
class AdminMetrics(BaseModel):
values: dict[str, float]
average_update_latency_ms: float
class AdminTotals(BaseModel):
hostnames: int
records: int
update_events: int
cleanup_runs: int
class AdminDashboardResponse(BaseModel):
totals: AdminTotals
metrics: AdminMetrics
hostnames: list[AdminHostname]
update_events: list[AdminUpdateEvent]
cleanup_runs: list[AdminCleanupRun]
scheduled_tasks: list[AdminScheduledTask]
limit: int
class AdminHostnamesResponse(BaseModel):
items: list[AdminHostname]
limit: int
offset: int
total: int
class AdminRecordsResponse(BaseModel):
items: list[AdminRecord]
limit: int
offset: int
total: int
class AdminEventsResponse(BaseModel):
items: list[AdminUpdateEvent]
limit: int
offset: int
next_before: str | None = None
total: int
def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameCreateResponse:
return HostnameCreateResponse(
hostname=account["hostname"],
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url(account),
dashboard_url=service.dashboard_url(account),
registration_url=service.registration_url(account),
)
def _hostname_status(
service: DdnsService,
account: dict[str, str | int | None],
history: list[dict[str, str | int | None]] | None = None,
) -> HostnameStatusResponse:
account_for_url = {"update_slug": str(account["update_slug"])}
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
return HostnameStatusResponse(
hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url(account_for_url),
dashboard_url=service.dashboard_url(account_for_dashboard) if account_for_dashboard["management_slug"] else "",
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,
history=[_hostname_update_event(event) for event in history or []],
)
def _hostname_rotate_update_response(service: DdnsService, account: dict[str, str | int | None], dashboard_secret: str) -> HostnameRotateUpdateResponse:
return HostnameRotateUpdateResponse(
hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
dashboard_url=service.dashboard_url({"management_slug": dashboard_secret}),
)
def _hostname_update_event(row: dict[str, str | int | None]) -> HostnameUpdateEvent:
return HostnameUpdateEvent(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
status=str(row["status"]),
detail=str(row["detail"]),
created_at=str(row["created_at"]),
count=int(row.get("count") or 1),
)
def _admin_hostname(account: dict[str, str | int | None]) -> AdminHostname:
return AdminHostname(
hostname=str(account["hostname"]),
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
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,
created_at=str(account["created_at"]) if account.get("created_at") else None,
accepted_at=str(account["accepted_at"]) if account.get("accepted_at") else None,
disabled=bool(account.get("disabled")),
)
def _admin_metrics(metrics: dict[str, float]) -> AdminMetrics:
samples = metrics.get("update_latency_samples_total", 0)
average_latency = metrics.get("update_latency_ms_total", 0) / samples if samples else 0
return AdminMetrics(values=metrics, average_update_latency_ms=average_latency)