2026-05-22 17:05:06 +02:00
|
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
|
|
2026-05-21 17:23:08 +02:00
|
|
|
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.")
|
2026-05-22 17:05:06 +02:00
|
|
|
registration_url: str = Field(
|
|
|
|
|
description="Secret URL used to register the pending hostname after secrets are saved."
|
|
|
|
|
)
|
2026-05-21 17:23:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
def _create_response(service: DdnsService, account: Mapping[str, object]) -> HostnameCreateResponse:
|
2026-05-21 17:23:08 +02:00
|
|
|
return HostnameCreateResponse(
|
2026-05-22 17:05:06 +02:00
|
|
|
hostname=str(account["hostname"]),
|
2026-05-21 17:23:08 +02:00
|
|
|
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,
|
2026-05-22 17:05:06 +02:00
|
|
|
account: Mapping[str, object],
|
|
|
|
|
history: Sequence[Mapping[str, object]] | None = None,
|
2026-05-21 17:23:08 +02:00
|
|
|
) -> HostnameStatusResponse:
|
|
|
|
|
return HostnameStatusResponse(
|
|
|
|
|
hostname=str(account["hostname"]),
|
2026-06-17 23:17:31 +02:00
|
|
|
status=_account_status(account, include_disabled=False),
|
|
|
|
|
update_url=service.fritz_update_url({"update_slug": str(account["update_slug"])}),
|
|
|
|
|
dashboard_url=_dashboard_url(service, account),
|
|
|
|
|
ipv4=_row_text(account, "ipv4"),
|
|
|
|
|
ipv6=_row_text(account, "ipv6"),
|
|
|
|
|
updated_at=_row_text(account, "updated_at"),
|
2026-05-21 17:23:08 +02:00
|
|
|
history=[_hostname_update_event(event) for event in history or []],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
def _hostname_rotate_update_response(
|
|
|
|
|
service: DdnsService, account: Mapping[str, object], dashboard_secret: str
|
|
|
|
|
) -> HostnameRotateUpdateResponse:
|
2026-05-21 17:23:08 +02:00
|
|
|
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}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
|
2026-05-21 17:23:08 +02:00
|
|
|
return HostnameUpdateEvent(
|
|
|
|
|
hostname=str(row["hostname"]),
|
2026-06-17 23:17:31 +02:00
|
|
|
ipv4=_row_text(row, "ipv4"),
|
|
|
|
|
ipv6=_row_text(row, "ipv6"),
|
2026-05-21 17:23:08 +02:00
|
|
|
status=str(row["status"]),
|
|
|
|
|
detail=str(row["detail"]),
|
|
|
|
|
created_at=str(row["created_at"]),
|
2026-05-22 17:05:06 +02:00
|
|
|
count=_int_value(row.get("count"), 1),
|
2026-05-21 17:23:08 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
def _admin_hostname(account: Mapping[str, object]) -> AdminHostname:
|
2026-05-21 17:23:08 +02:00
|
|
|
return AdminHostname(
|
|
|
|
|
hostname=str(account["hostname"]),
|
2026-06-17 23:17:31 +02:00
|
|
|
status=_account_status(account),
|
|
|
|
|
ipv4=_row_text(account, "ipv4"),
|
|
|
|
|
ipv6=_row_text(account, "ipv6"),
|
|
|
|
|
updated_at=_row_text(account, "updated_at"),
|
|
|
|
|
created_at=_row_text(account, "created_at"),
|
|
|
|
|
accepted_at=_row_text(account, "accepted_at"),
|
2026-05-21 17:23:08 +02:00
|
|
|
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)
|
2026-05-22 17:05:06 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _admin_record(row: Mapping[str, object]) -> AdminRecord:
|
|
|
|
|
return AdminRecord(
|
|
|
|
|
hostname=str(row["hostname"]),
|
2026-06-17 23:17:31 +02:00
|
|
|
ipv4=_row_text(row, "ipv4"),
|
|
|
|
|
ipv6=_row_text(row, "ipv6"),
|
2026-05-22 17:05:06 +02:00
|
|
|
updated_at=str(row["updated_at"]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent:
|
|
|
|
|
return AdminUpdateEvent(
|
|
|
|
|
hostname=str(row["hostname"]),
|
2026-06-17 23:17:31 +02:00
|
|
|
ipv4=_row_text(row, "ipv4"),
|
|
|
|
|
ipv6=_row_text(row, "ipv6"),
|
2026-05-22 17:05:06 +02:00
|
|
|
status=str(row["status"]),
|
|
|
|
|
detail=str(row["detail"]),
|
|
|
|
|
source_ip=str(row["source_ip"]),
|
|
|
|
|
created_at=str(row["created_at"]),
|
|
|
|
|
count=_int_value(row.get("count"), 1),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _admin_cleanup_run(row: Mapping[str, object]) -> AdminCleanupRun:
|
|
|
|
|
return AdminCleanupRun(
|
|
|
|
|
created_at=str(row["created_at"]),
|
|
|
|
|
unused_accounts_deleted=_int_value(row["unused_accounts_deleted"], 0),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _admin_scheduled_task(row: Mapping[str, object]) -> AdminScheduledTask:
|
|
|
|
|
return AdminScheduledTask(
|
|
|
|
|
name=str(row["name"]),
|
|
|
|
|
interval_seconds=_int_value(row["interval_seconds"], 0),
|
|
|
|
|
next_run_at=str(row["next_run_at"]),
|
2026-06-17 23:17:31 +02:00
|
|
|
lease_owner=_row_text(row, "lease_owner"),
|
|
|
|
|
lease_expires_at=_row_text(row, "lease_expires_at"),
|
|
|
|
|
last_started_at=_row_text(row, "last_started_at"),
|
|
|
|
|
last_finished_at=_row_text(row, "last_finished_at"),
|
2026-05-22 17:05:06 +02:00
|
|
|
last_status=str(row["last_status"]),
|
|
|
|
|
last_error=str(row["last_error"]),
|
|
|
|
|
updated_at=str(row["updated_at"]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 23:17:31 +02:00
|
|
|
def _row_text(row: Mapping[str, object], key: str) -> str | None:
|
|
|
|
|
value = row.get(key)
|
|
|
|
|
return str(value) if value else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _account_status(account: Mapping[str, object], *, include_disabled: bool = True) -> str:
|
|
|
|
|
if include_disabled and account.get("disabled"):
|
|
|
|
|
return "disabled"
|
|
|
|
|
if account.get("accepted_at"):
|
|
|
|
|
return "active"
|
|
|
|
|
return "pending"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dashboard_url(service: DdnsService, account: Mapping[str, object]) -> str:
|
|
|
|
|
management_slug = _row_text(account, "management_slug")
|
|
|
|
|
if management_slug is None:
|
|
|
|
|
return ""
|
|
|
|
|
return service.dashboard_url({"management_slug": management_slug})
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
def _int_value(value: object, default: int) -> int:
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
if isinstance(value, int):
|
|
|
|
|
return value
|
|
|
|
|
if isinstance(value, float):
|
|
|
|
|
return int(value)
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
return int(value)
|
|
|
|
|
return default
|