Add formatting and type checks

This commit is contained in:
StasonJatham committed 2026-05-22 17:05:06 +02:00
1 parent c0d82eaf26
commit 24747866bc
38 files changed
+800 -265

No files matched your search

+6 -3
View File
@@ -4,6 +4,7 @@ import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, cast
from ..models import UpdateResult
from ..settings import DdnsSettings
@@ -49,11 +50,13 @@ def _cloudflare_upsert_record(settings: DdnsSettings, hostname: str, record_type
_cloudflare_reconcile_record(settings, hostname, record_type, value)
def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, str] | None:
def _cloudflare_find_record(settings: DdnsSettings, hostname: str, record_type: str) -> dict[str, Any] | None:
query = urllib.parse.urlencode({"type": record_type, "name": hostname})
response = _cloudflare_request(settings, f"/dns_records?{query}")
records = response.get("result", [])
return records[0] if records else None
if not isinstance(records, list) or not records:
return None
return cast(dict[str, Any], records[0])
def _cloudflare_reconcile_record(settings: DdnsSettings, hostname: str, record_type: str, value: str) -> None:
@@ -69,7 +72,7 @@ def _cloudflare_request(
path: str,
method: str = "GET",
payload: dict[str, object] | None = None,
) -> dict[str, object]:
) -> dict[str, Any]:
url = f"https://api.cloudflare.com/client/v4/zones/{settings.cloudflare_zone_id}{path}"
body = json.dumps(payload).encode("utf-8") if payload is not None else None
request = urllib.request.Request(url, data=body, method=method)
+18 -3
View File
@@ -67,7 +67,9 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
cleanup_events: int = Query(0, ge=0),
) -> str:
notice = (
_admin_notice("success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events.")
_admin_notice(
"success", f"Cleanup finished. Removed {cleanup_unused} hostnames and {cleanup_events} old events."
)
if cleanup_done
else None
)
@@ -138,7 +140,13 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"router update URL rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated), "Secret")]) if rotated else None
notice = (
_admin_secret_panel(
"Update URL changed", [_copy_row("Update-URL:", service.fritz_update_url(rotated), "Secret")]
)
if rotated
else None
)
elif action == "management":
rotated = await run_in_threadpool(store.rotate_management_slug, hostname)
await run_in_threadpool(
@@ -150,7 +158,14 @@ def make_router(admin_path: str = "/admin") -> APIRouter:
"dashboard link rotated by admin",
client_ip(request, settings),
)
notice = _admin_secret_panel("Dashboard link changed", [_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")]) if rotated else None
notice = (
_admin_secret_panel(
"Dashboard link changed",
[_copy_row("Private dashboard:", service.dashboard_url(rotated), "Secret")],
)
if rotated
else None
)
else:
raise HTTPException(status_code=400, detail="invalid rotation action")
page = await run_in_threadpool(render_admin, service, settings, store, None, notice)
+25 -6
View File
@@ -16,8 +16,12 @@ from ..schemas import (
HostnameCreateResponse,
HostnameRotateUpdateResponse,
HostnameStatusResponse,
_admin_cleanup_run,
_admin_hostname,
_admin_metrics,
_admin_record,
_admin_scheduled_task,
_admin_update_event,
_create_response,
_hostname_rotate_update_response,
_hostname_status,
@@ -62,7 +66,11 @@ async def read_hostname(
if not account or account["disabled"]:
raise HTTPException(status_code=404, detail="not found")
account["management_slug"] = dashboard_secret
history = await run_in_threadpool(store.list_update_events_for_hostname, str(account["hostname"]), history_limit) if history_limit else []
history = (
await run_in_threadpool(store.list_update_events_for_hostname, str(account["hostname"]), history_limit)
if history_limit
else []
)
return _hostname_status(service, account, history)
@@ -149,9 +157,9 @@ async def read_admin(
),
metrics=_admin_metrics(metrics),
hostnames=[_admin_hostname(account) for account in accounts],
update_events=events,
cleanup_runs=cleanup_runs,
scheduled_tasks=scheduled_tasks,
update_events=[_admin_update_event(event) for event in events],
cleanup_runs=[_admin_cleanup_run(cleanup_run) for cleanup_run in cleanup_runs],
scheduled_tasks=[_admin_scheduled_task(task) for task in scheduled_tasks],
limit=limit,
)
@@ -204,7 +212,13 @@ async def list_admin_events(
)
next_before = str(items[-1]["created_at"]) if len(items) == limit and items[-1].get("created_at") else None
total = await run_in_threadpool(store.count_update_events)
return AdminEventsResponse(items=items, limit=limit, offset=offset, next_before=next_before, total=total)
return AdminEventsResponse(
items=[_admin_update_event(item) for item in items],
limit=limit,
offset=offset,
next_before=next_before,
total=total,
)
@router.get(
@@ -224,4 +238,9 @@ async def list_admin_records(
) -> AdminRecordsResponse:
items = await run_in_threadpool(store.list_records_page, limit, offset)
total = await run_in_threadpool(store.count_records)
return AdminRecordsResponse(items=items, limit=limit, offset=offset, total=total)
return AdminRecordsResponse(
items=[_admin_record(item) for item in items],
limit=limit,
offset=offset,
total=total,
)
+23 -1
View File
@@ -14,7 +14,16 @@ ASSET_CACHE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
def _minify_css(css: str) -> str:
return " ".join(css.split()).replace(" {", "{").replace("{ ", "{").replace("; ", ";").replace(": ", ":").replace(", ", ",").replace(" }", "}").replace(";}", "}")
return (
" ".join(css.split())
.replace(" {", "{")
.replace("{ ", "{")
.replace("; ", ";")
.replace(": ", ":")
.replace(", ", ",")
.replace(" }", "}")
.replace(";}", "}")
)
MINIFIED_BASE_CSS = _minify_css(BASE_CSS)
@@ -25,50 +34,62 @@ MINIFIED_ADMIN_CSS = _minify_css(ADMIN_CSS)
def favicon() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon.ico", media_type="image/x-icon", headers=ASSET_CACHE_HEADERS)
@router.get("/bootstrap-subset.css")
def bootstrap_subset_css() -> FileResponse:
return FileResponse(ASSET_DIR / "bootstrap-subset.css", media_type="text/css", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-16x16.png")
def favicon_16_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-16x16.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/favicon-32x32.png")
def favicon_32_png() -> FileResponse:
return FileResponse(ASSET_DIR / "favicon-32x32.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/apple-touch-icon.png")
def apple_touch_icon_png() -> FileResponse:
return FileResponse(ASSET_DIR / "apple-touch-icon.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.png")
def logo_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo.webp")
def logo_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-nav.webp")
def logo_nav_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-nav.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-192.png")
def logo_192_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-192.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/logo-512.png")
def logo_512_png() -> FileResponse:
return FileResponse(ASSET_DIR / "logo-512.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.png")
def social_preview_png() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.png", media_type="image/png", headers=ASSET_CACHE_HEADERS)
@router.get("/social-preview.webp")
def social_preview_webp() -> FileResponse:
return FileResponse(ASSET_DIR / "social-preview.webp", media_type="image/webp", headers=ASSET_CACHE_HEADERS)
@router.get("/site.webmanifest")
def site_webmanifest() -> Response:
return Response(
@@ -92,6 +113,7 @@ def site_webmanifest() -> Response:
headers=ASSET_CACHE_HEADERS,
)
@router.get("/theme.js")
def theme_js() -> Response:
return Response(THEME_JS, media_type="application/javascript", headers=ASSET_CACHE_HEADERS)
+27 -14
View File
@@ -50,14 +50,24 @@ def manage_dashboard(
account = get_active_management_account(store, management_slug)
csrf_token = dashboard_csrf_token(management_slug, account)
if not management_account_is_accepted(account):
return _render_pending_management_page(service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings))
return _render_pending_management_page(
service, settings, management_slug, account, csrf_token, request_ip=client_ip(request, settings)
)
if not has_management_access(request, settings, management_slug, account):
return _render_management_unlock_page(management_slug, account, csrf_token)
message = "Hostname registered. Add the update URL to your router." if activated else None
return _render_management_page(service, settings, management_slug, account, csrf_token, message=message, request_ip=client_ip(request, settings))
return _render_management_page(
service,
settings,
management_slug,
account,
csrf_token,
message=message,
request_ip=client_ip(request, settings),
)
@router.post("/m/{management_slug}/activate", include_in_schema=False)
@router.post("/m/{management_slug}/activate", include_in_schema=False, response_model=None)
async def activate_dashboard(
management_slug: ManagementSlugPath,
request: Request,
@@ -68,7 +78,7 @@ async def activate_dashboard(
ipaddr: str | None = None,
myipv6: str | None = None,
ip6addr: str | None = None,
) -> RedirectResponse:
) -> RedirectResponse | HTMLResponse:
account = await get_active_management_account_async(store, management_slug)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
@@ -102,8 +112,8 @@ async def activate_dashboard(
),
status_code=500,
)
account = await run_in_threadpool(store.activate_account, hostname)
if not account:
activated_account = await run_in_threadpool(lambda: store.activate_account(hostname))
if not activated_account:
return _not_found()
await run_in_threadpool(
store.log_update_event,
@@ -117,14 +127,14 @@ async def activate_dashboard(
return RedirectResponse(f"/m/{management_slug}?activated=1", status_code=303)
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False)
@router.post("/m/{management_slug}/rotate", response_class=HTMLResponse, include_in_schema=False, response_model=None)
async def rotate_dashboard_secrets(
management_slug: ManagementSlugPath,
request: Request,
settings: SettingsDep,
store: StoreDep,
service: ServiceDep,
) -> str:
) -> str | HTMLResponse:
account = await get_active_management_account_async(store, management_slug)
if not management_account_is_accepted(account):
return HTMLResponse(
@@ -172,10 +182,15 @@ async def rotate_dashboard_secrets(
@router.post("/m/{management_slug}/delete", include_in_schema=False)
async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep) -> Response:
async def delete_dashboard_hostname(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep, service: ServiceDep
) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
@@ -195,8 +210,7 @@ async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request
return HTMLResponse(
_page(
"Hostname deleted",
trusted(
"""
trusted("""
<main>
<section class="hero-band compact">
<div class="container text-center d-grid justify-items-center gap-3">
@@ -207,8 +221,7 @@ async def delete_dashboard_hostname(management_slug: ManagementSlugPath, request
</div>
</section>
</main>
"""
),
"""),
)
)
+14 -4
View File
@@ -24,7 +24,9 @@ router = APIRouter()
@router.post("/m/{management_slug}/unlock", include_in_schema=False)
async def unlock_dashboard(management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep) -> Response:
async def unlock_dashboard(
management_slug: ManagementSlugPath, request: Request, settings: SettingsDep, store: StoreDep
) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not account.get("dashboard_password_hash"):
return RedirectResponse(f"/m/{management_slug}", status_code=303)
@@ -35,7 +37,9 @@ async def unlock_dashboard(management_slug: ManagementSlugPath, request: Request
is_valid = await run_in_threadpool(store.verify_dashboard_password, hostname, password)
if not is_valid:
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect password."),
_render_management_unlock_page(
management_slug, account, dashboard_csrf_token(management_slug, account), "Incorrect password."
),
status_code=401,
)
response = RedirectResponse(f"/m/{management_slug}", status_code=303)
@@ -59,7 +63,10 @@ async def set_dashboard_password(
) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
@@ -129,7 +136,10 @@ async def remove_dashboard_password(
) -> Response:
account = await get_active_management_account_async(store, management_slug)
if not has_management_access(request, settings, management_slug, account):
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
form = await request_form(request, settings)
require_dashboard_csrf(management_slug, account, first_form_value(form, "csrf"))
hostname = str(account["hostname"])
+10 -3
View File
@@ -35,7 +35,9 @@ def management_account_is_accepted(account: ManagementAccount) -> bool:
return bool(account.get("accepted_at"))
def has_management_access(request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount) -> bool:
def has_management_access(
request: Request, settings: DdnsSettings, management_slug: str, account: ManagementAccount
) -> bool:
password_hash = account.get("dashboard_password_hash")
if not password_hash:
return True
@@ -49,7 +51,10 @@ def has_management_access(request: Request, settings: DdnsSettings, management_s
def locked_management_response(management_slug: str, account: ManagementAccount) -> HTMLResponse:
return HTMLResponse(_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)), status_code=401)
return HTMLResponse(
_render_management_unlock_page(management_slug, account, dashboard_csrf_token(management_slug, account)),
status_code=401,
)
def management_error_response(
@@ -82,7 +87,9 @@ async def management_error_response_async(
message: str,
request_ip: str | None = None,
) -> HTMLResponse:
return await run_in_threadpool(management_error_response, service, settings, management_slug, account, message, request_ip)
return await run_in_threadpool(
management_error_response, service, settings, management_slug, account, message, request_ip
)
async def render_management_page_async(
+3 -1
View File
@@ -152,7 +152,9 @@ async def _update_hostname(
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"]), myip, ipaddr, myipv6, ip6addr)
return await run_in_threadpool(
apply_update_response, service, request, str(account["hostname"]), myip, ipaddr, myipv6, ip6addr
)
async def _ip_params_from_request(
+71 -9
View File
@@ -1,3 +1,5 @@
from collections.abc import Mapping, Sequence
from pydantic import BaseModel, Field
from .services.ddns import DdnsService
@@ -12,7 +14,9 @@ class HostnameCreateResponse(BaseModel):
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.")
registration_url: str = Field(
description="Secret URL used to register the pending hostname after secrets are saved."
)
class HostnameStatusResponse(BaseModel):
@@ -134,9 +138,9 @@ class AdminEventsResponse(BaseModel):
total: int
def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameCreateResponse:
def _create_response(service: DdnsService, account: Mapping[str, object]) -> HostnameCreateResponse:
return HostnameCreateResponse(
hostname=account["hostname"],
hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending",
update_url=service.fritz_update_url(account),
dashboard_url=service.dashboard_url(account),
@@ -146,8 +150,8 @@ def _create_response(service: DdnsService, account: dict[str, str]) -> HostnameC
def _hostname_status(
service: DdnsService,
account: dict[str, str | int | None],
history: list[dict[str, str | int | None]] | None = None,
account: Mapping[str, object],
history: Sequence[Mapping[str, object]] | None = None,
) -> HostnameStatusResponse:
account_for_url = {"update_slug": str(account["update_slug"])}
account_for_dashboard = {"management_slug": str(account.get("management_slug", ""))}
@@ -163,7 +167,9 @@ def _hostname_status(
)
def _hostname_rotate_update_response(service: DdnsService, account: dict[str, str | int | None], dashboard_secret: str) -> HostnameRotateUpdateResponse:
def _hostname_rotate_update_response(
service: DdnsService, account: Mapping[str, object], dashboard_secret: str
) -> HostnameRotateUpdateResponse:
return HostnameRotateUpdateResponse(
hostname=str(account["hostname"]),
status="active" if account.get("accepted_at") else "pending",
@@ -172,7 +178,7 @@ def _hostname_rotate_update_response(service: DdnsService, account: dict[str, st
)
def _hostname_update_event(row: dict[str, str | int | None]) -> HostnameUpdateEvent:
def _hostname_update_event(row: Mapping[str, object]) -> HostnameUpdateEvent:
return HostnameUpdateEvent(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
@@ -180,11 +186,11 @@ def _hostname_update_event(row: dict[str, str | int | None]) -> HostnameUpdateEv
status=str(row["status"]),
detail=str(row["detail"]),
created_at=str(row["created_at"]),
count=int(row.get("count") or 1),
count=_int_value(row.get("count"), 1),
)
def _admin_hostname(account: dict[str, str | int | None]) -> AdminHostname:
def _admin_hostname(account: Mapping[str, object]) -> AdminHostname:
return AdminHostname(
hostname=str(account["hostname"]),
status="disabled" if account.get("disabled") else ("active" if account.get("accepted_at") else "pending"),
@@ -201,3 +207,59 @@ 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)
def _admin_record(row: Mapping[str, object]) -> AdminRecord:
return AdminRecord(
hostname=str(row["hostname"]),
ipv4=str(row["ipv4"]) if row.get("ipv4") else None,
ipv6=str(row["ipv6"]) if row.get("ipv6") else None,
updated_at=str(row["updated_at"]),
)
def _admin_update_event(row: Mapping[str, object]) -> AdminUpdateEvent:
return AdminUpdateEvent(
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"]),
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"]),
lease_owner=str(row["lease_owner"]) if row.get("lease_owner") else None,
lease_expires_at=str(row["lease_expires_at"]) if row.get("lease_expires_at") else None,
last_started_at=str(row["last_started_at"]) if row.get("last_started_at") else None,
last_finished_at=str(row["last_finished_at"]) if row.get("last_finished_at") else None,
last_status=str(row["last_status"]),
last_error=str(row["last_error"]),
updated_at=str(row["updated_at"]),
)
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
+3 -2
View File
@@ -5,6 +5,7 @@ import hashlib
import hmac
import secrets
import time
from collections.abc import Mapping
from datetime import UTC, datetime
from ipaddress import ip_address, ip_network
@@ -135,7 +136,7 @@ def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str:
return settings.dashboard_cookie_secret or settings.admin_password
def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | None]) -> str:
def dashboard_csrf_token(management_slug: str, account: Mapping[str, object]) -> str:
hostname = str(account["hostname"])
password_hash = str(account.get("dashboard_password_hash") or "")
day = datetime.now(UTC).strftime("%Y-%m-%d")
@@ -143,7 +144,7 @@ def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | No
return hmac.new(key, f"dashboard:{hostname}:{day}".encode(), hashlib.sha256).hexdigest()
def require_dashboard_csrf(management_slug: str, account: dict[str, str | int | None], supplied: str) -> None:
def require_dashboard_csrf(management_slug: str, account: Mapping[str, object], supplied: str) -> None:
if not supplied or not hmac.compare_digest(supplied, dashboard_csrf_token(management_slug, account)):
raise HTTPException(status_code=403, detail="invalid csrf token")
+23 -11
View File
@@ -8,6 +8,7 @@ import sqlite3
import threading
import time
import urllib.parse
from collections.abc import Mapping
from dataclasses import dataclass
from fastapi import HTTPException, Request
@@ -18,6 +19,7 @@ from ..models import UpdateResult
from ..security import client_ip
from ..settings import DdnsSettings
from ..store import DdnsStore
from ..store.types import AccountRow
LOGGER = logging.getLogger(__name__)
INITIAL_PLACEHOLDER_IPV4 = "192.0.2.1"
@@ -41,7 +43,7 @@ class DdnsService:
self.store = store
self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
def create_managed_account(self) -> dict[str, str]:
def create_managed_account(self) -> AccountRow:
if not self.settings.hostname_suffix:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required")
for _ in range(5):
@@ -79,6 +81,8 @@ class DdnsService:
source_ip = client_ip(request, self.settings)
source_fallback = source_ip if _is_global_ip(source_ip) else ""
supplied_ip = any(value and value.strip() for value in (myip, ipaddr, myipv6, ip6addr))
ipv4: str | None
ipv6: str | None
if not supplied_ip and not source_fallback:
ipv4 = INITIAL_PLACEHOLDER_IPV4
ipv6 = None
@@ -108,7 +112,9 @@ class DdnsService:
planned.update_ipv6,
type(exc).__name__,
)
self.store.log_update_event(hostname, ipv4, ipv6, DNS_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
self.store.log_update_event(
hostname, ipv4, ipv6, DNS_RECONCILE_FAILED, f"{type(exc).__name__}: {exc}", source_ip
)
raise
except DnsProviderError as exc:
LOGGER.warning(
@@ -118,7 +124,9 @@ class DdnsService:
planned.update_ipv6,
type(exc).__name__,
)
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
self.store.log_update_event(
hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip
)
raise
result = self.store.commit_update_result(planned)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, detail, source_ip)
@@ -149,7 +157,9 @@ class DdnsService:
)
lock = self._lock_for(hostname)
with lock:
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
planned = self.store.preview_update(
hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6
)
if planned.changed:
try:
publish_dns(planned, self.settings)
@@ -180,7 +190,9 @@ class DdnsService:
planned.update_ipv6,
type(exc).__name__,
)
self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip)
self.store.log_update_event(
hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip
)
raise
result = self.store.commit_update_result(planned)
is_success = True
@@ -203,19 +215,19 @@ class DdnsService:
is_bad_ip=is_bad_ip,
)
def fritz_update_url(self, account: dict[str, str]) -> str:
def fritz_update_url(self, account: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["update_slug"])
slug = urllib.parse.quote(str(account["update_slug"]))
return f"{base}/api/v1/router/update/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
def dashboard_url(self, account: dict[str, str]) -> str:
def dashboard_url(self, account: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"])
slug = urllib.parse.quote(str(account["management_slug"]))
return f"{base}/m/{slug}"
def registration_url(self, account: dict[str, str]) -> str:
def registration_url(self, account: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"])
slug = urllib.parse.quote(str(account["management_slug"]))
return f"{base}/api/v1/router/register/{slug}"
def random_managed_hostname(self) -> str:
+2 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from sqlite3 import Connection
@@ -31,7 +32,7 @@ class DdnsStore(AccountsMixin, MetricsMixin, RecordsEventsMixin, RateLimitCleanu
self._init_db()
@contextmanager
def connect(self):
def connect(self) -> Iterator[Connection]:
conn = self._thread_connection()
try:
yield conn
+7 -6
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from ..security import hash_lookup_token, hash_secret, new_bearer_secret, verify_secret
from .types import AccountRow
from .base import StoreConnectionMixin
from .types import AccountRow, account_row
class AccountsMixin:
class AccountsMixin(StoreConnectionMixin):
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
now = datetime.now(UTC).isoformat()
update_slug = new_bearer_secret()
@@ -41,7 +42,7 @@ class AccountsMixin:
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
return [account_row(row) for row in rows]
def count_accounts(self) -> int:
with self.connect() as conn:
@@ -60,7 +61,7 @@ class AccountsMixin:
""",
(hostname,),
).fetchone()
return dict(account) if account else None
return account_row(account) if account else None
def get_account_by_slug(self, update_slug: str) -> AccountRow | None:
with self.connect() as conn:
@@ -71,7 +72,7 @@ class AccountsMixin:
""",
(update_slug,),
).fetchone()
return dict(account) if account else None
return account_row(account) if account else None
def get_account_by_management_slug(self, management_slug: str) -> AccountRow | None:
with self.connect() as conn:
@@ -85,7 +86,7 @@ class AccountsMixin:
""",
(hash_lookup_token(management_slug),),
).fetchone()
return dict(account) if account else None
return account_row(account) if account else None
def activate_account(self, hostname: str) -> AccountRow | None:
now = datetime.now(UTC).isoformat()
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
import sqlite3
from contextlib import AbstractContextManager
class StoreConnectionMixin:
def connect(self) -> AbstractContextManager[sqlite3.Connection]:
raise NotImplementedError
+3 -1
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
from datetime import UTC, datetime
from .base import StoreConnectionMixin
class MetricsMixin:
class MetricsMixin(StoreConnectionMixin):
def increment_metric(self, name: str, amount: float = 1.0) -> None:
now = datetime.now(UTC).isoformat()
with self.connect() as conn:
+12 -3
View File
@@ -1,14 +1,23 @@
from __future__ import annotations
import threading
import time
from datetime import UTC, datetime, timedelta
from .types import CleanupRunRow
from .base import StoreConnectionMixin
from .types import CleanupRunRow, cleanup_run_row
RATE_LIMIT_FLUSH_SECONDS = 10.0
class RateLimitCleanupMixin:
class RateLimitCleanupMixin(StoreConnectionMixin):
_rate_limit_lock: threading.Lock
_rate_limit_prune_lock: threading.Lock
_rate_limit_buckets: dict[str, dict[str, float | int]]
_rate_limit_dirty_counts: dict[tuple[str, int], int]
_last_rate_limit_flush: float
_last_rate_limit_prune_window: int
def list_cleanup_runs(self, limit: int = 20, offset: int = 0) -> list[CleanupRunRow]:
with self.connect() as conn:
rows = conn.execute(
@@ -20,7 +29,7 @@ class RateLimitCleanupMixin:
""",
(limit, offset),
).fetchall()
return [CleanupRunRow(**dict(row)) for row in rows]
return [cleanup_run_row(row) for row in rows]
def count_cleanup_runs(self) -> int:
with self.connect() as conn:
+10 -9
View File
@@ -4,10 +4,11 @@ import sqlite3
from datetime import UTC, datetime
from ..models import UpdateResult
from .types import UpdateEventRow
from .base import StoreConnectionMixin
from .types import RecordRow, UpdateEventRow, record_row, update_event_row
class RecordsEventsMixin:
class RecordsEventsMixin(StoreConnectionMixin):
def upsert(
self,
hostname: str,
@@ -84,10 +85,10 @@ class RecordsEventsMixin:
update_ipv6=update_ipv6,
)
def list_records(self) -> list[dict[str, str | None]]:
def list_records(self) -> list[RecordRow]:
return self.list_records_page(limit=10_000, offset=0)
def list_records_page(self, limit: int = 100, offset: int = 0) -> list[dict[str, str | None]]:
def list_records_page(self, limit: int = 100, offset: int = 0) -> list[RecordRow]:
with self.connect() as conn:
rows = conn.execute(
"""
@@ -98,7 +99,7 @@ class RecordsEventsMixin:
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
return [record_row(row) for row in rows]
def count_records(self) -> int:
with self.connect() as conn:
@@ -167,7 +168,7 @@ class RecordsEventsMixin:
""",
(limit, offset),
).fetchall()
return [dict(row) for row in rows]
return [update_event_row(row) for row in rows]
def list_update_events_before(self, limit: int = 100, before: str | None = None) -> list[UpdateEventRow]:
where_clause = "WHERE created_at < ?" if before else ""
@@ -189,7 +190,7 @@ class RecordsEventsMixin:
""",
params,
).fetchall()
return [dict(row) for row in rows]
return [update_event_row(row) for row in rows]
def count_update_events(self) -> int:
with self.connect() as conn:
@@ -228,7 +229,7 @@ def _merge_event_rows(
limit: int,
offset: int,
) -> list[UpdateEventRow]:
rows = [dict(row) for row in event_rows]
rows.extend(dict(row) for row in rollup_rows)
rows = [update_event_row(row) for row in event_rows]
rows.extend(update_event_row(row) for row in rollup_rows)
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
return rows[offset : offset + limit]
+6 -7
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
from .types import ScheduledTaskRow
from .base import StoreConnectionMixin
from .types import ScheduledTaskRow, scheduled_task_row
def _now() -> datetime:
@@ -13,7 +14,7 @@ def _iso(value: datetime) -> str:
return value.isoformat()
class SchedulerMixin:
class SchedulerMixin(StoreConnectionMixin):
def ensure_scheduled_task(self, name: str, interval_seconds: int) -> None:
now = _now()
with self.connect() as conn:
@@ -86,12 +87,10 @@ class SchedulerMixin:
def list_scheduled_tasks(self) -> list[ScheduledTaskRow]:
with self.connect() as conn:
rows = conn.execute(
"""
rows = conn.execute("""
SELECT name, interval_seconds, next_run_at, lease_owner, lease_expires_at,
last_started_at, last_finished_at, last_status, last_error, updated_at
FROM scheduled_tasks
ORDER BY name
"""
).fetchall()
return [ScheduledTaskRow(**dict(row)) for row in rows]
""").fetchall()
return [scheduled_task_row(row) for row in rows]
+32 -56
View File
@@ -35,18 +35,15 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
configure_connection(conn)
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
conn.execute("PRAGMA journal_mode = WAL")
conn.execute(
"""
conn.execute("""
CREATE TABLE IF NOT EXISTS records (
hostname TEXT PRIMARY KEY,
ipv4 TEXT,
ipv6 TEXT,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS update_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
@@ -57,12 +54,12 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
source_ip TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_created_at ON update_events(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_events_hostname_created_at ON update_events(hostname, created_at DESC)")
conn.execute(
"""
"CREATE INDEX IF NOT EXISTS idx_update_events_hostname_created_at ON update_events(hostname, created_at DESC)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS update_event_rollups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL,
@@ -76,28 +73,24 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
count INTEGER NOT NULL,
UNIQUE(hostname, ipv4, ipv6, status, detail, source_ip)
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_update_event_rollups_last_seen_at ON update_event_rollups(last_seen_at)")
""")
conn.execute(
"""
"CREATE INDEX IF NOT EXISTS idx_update_event_rollups_last_seen_at ON update_event_rollups(last_seen_at)"
)
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_update_event_rollups_hostname_last_seen_at
ON update_event_rollups(hostname, last_seen_at DESC)
"""
)
conn.execute(
"""
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS cleanup_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
""")
_migrate_cleanup_runs_schema(conn)
conn.execute("CREATE INDEX IF NOT EXISTS idx_cleanup_runs_created_at ON cleanup_runs(created_at)")
conn.execute(
"""
conn.execute("""
CREATE TABLE IF NOT EXISTS scheduled_tasks (
name TEXT PRIMARY KEY,
interval_seconds INTEGER NOT NULL,
@@ -110,37 +103,29 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
last_error TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
)
"""
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run_at ON scheduled_tasks(next_run_at)")
conn.execute(
"""
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
ON scheduled_tasks(next_run_at, lease_expires_at)
"""
)
conn.execute(
"""
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT NOT NULL,
window INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY(key, window)
)
"""
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_rate_limits_window ON rate_limits(window)")
conn.execute(
"""
conn.execute("""
CREATE TABLE IF NOT EXISTS service_metrics (
name TEXT PRIMARY KEY,
value REAL NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS accounts (
hostname TEXT PRIMARY KEY,
update_slug TEXT NOT NULL UNIQUE,
@@ -150,8 +135,7 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
""")
_ensure_column(conn, "accounts", "update_slug", "TEXT")
_ensure_column(conn, "accounts", "management_hash", "TEXT")
_ensure_column(conn, "accounts", "dashboard_password_hash", "TEXT")
@@ -190,8 +174,7 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
conn.execute("DROP INDEX IF EXISTS idx_accounts_accepted_at")
conn.execute("DROP INDEX IF EXISTS idx_accounts_created_at")
conn.execute("DROP INDEX IF EXISTS idx_accounts_hostname_username")
conn.execute(
"""
conn.execute("""
CREATE TABLE accounts_new (
hostname TEXT PRIMARY KEY,
update_slug TEXT NOT NULL UNIQUE,
@@ -201,15 +184,12 @@ def _migrate_accounts_schema(conn: sqlite3.Connection) -> None:
accepted_at TEXT,
disabled INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
""")
conn.execute("""
INSERT INTO accounts_new(hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled)
SELECT hostname, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled
FROM accounts
"""
)
""")
conn.execute("DROP TABLE accounts")
conn.execute("ALTER TABLE accounts_new RENAME TO accounts")
@@ -219,21 +199,17 @@ def _migrate_cleanup_runs_schema(conn: sqlite3.Connection) -> None:
if columns <= {"id", "created_at", "unused_accounts_deleted"}:
return
conn.execute("DROP INDEX IF EXISTS idx_cleanup_runs_created_at")
conn.execute(
"""
conn.execute("""
CREATE TABLE cleanup_runs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
unused_accounts_deleted INTEGER NOT NULL
)
"""
)
conn.execute(
"""
""")
conn.execute("""
INSERT INTO cleanup_runs_new(id, created_at, unused_accounts_deleted)
SELECT id, created_at, unused_accounts_deleted
FROM cleanup_runs
"""
)
""")
conn.execute("DROP TABLE cleanup_runs")
conn.execute("ALTER TABLE cleanup_runs_new RENAME TO cleanup_runs")
+28 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TypedDict
from typing import Any, TypedDict, cast
class AccountRow(TypedDict, total=False):
@@ -43,3 +43,30 @@ class ScheduledTaskRow(TypedDict, total=False):
last_status: str
last_error: str
updated_at: str
class RecordRow(TypedDict):
hostname: str
ipv4: str | None
ipv6: str | None
updated_at: str
def account_row(row: Any) -> AccountRow:
return cast(AccountRow, dict(row))
def cleanup_run_row(row: Any) -> CleanupRunRow:
return cast(CleanupRunRow, dict(row))
def record_row(row: Any) -> RecordRow:
return cast(RecordRow, dict(row))
def scheduled_task_row(row: Any) -> ScheduledTaskRow:
return cast(ScheduledTaskRow, dict(row))
def update_event_row(row: Any) -> UpdateEventRow:
return cast(UpdateEventRow, dict(row))
+18 -26
View File
@@ -46,8 +46,7 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_se
else ""
)
row_class = "copy-field copy-field-secret" if is_secret else "copy-field copy-field-public"
return trusted(
f"""
return trusted(f"""
<div class="{row_class}">
<div class="copy-pill">
<span class="copy-pill-label">{safe_label}</span>
@@ -58,8 +57,7 @@ def _copy_row(label: str, value: str, sensitivity: str | None = None, *, mask_se
{_svg_icon("clipboard")}<span data-copy-label>Copy</span>{_svg_icon("check", "check-icon")}
</button>
</div>
"""
)
""")
def _copy_button(label: str, value: str, *, style: str = "outline-primary") -> SafeHtml:
@@ -89,8 +87,7 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
f'<button type="button" class="copy-menu-item" data-copy="{attr(item_value)}" aria-label="Copy {attr(item_label)}">'
f'{_svg_icon("clipboard")}<span data-copy-label>{escape(item_label)}</span>{_svg_icon("check", "check-icon")}</button>'
)
return trusted(
f"""
return trusted(f"""
<details class="copy-menu">
<summary aria-label="{safe_label}" title="{safe_label}">
{_svg_icon("more", "more-icon")}
@@ -99,22 +96,21 @@ def _copy_menu(label: str, items: list[tuple[str, str]]) -> SafeHtml:
{''.join(buttons)}
</div>
</details>
"""
)
""")
def _code_window(commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None) -> SafeHtml:
def _code_window(
commands: str, *, copy_label: str = "code", class_name: str = "", copy_value: str | None = None
) -> SafeHtml:
safe_class = f" {attr(class_name)}" if class_name else ""
code_lines = "\n".join(f"<code>{escape(line)}</code>" for line in commands.splitlines())
copy_value = copy_value if copy_value is not None else _code_copy_value(commands)
return trusted(
f"""
return trusted(f"""
<div class="terminal-card code-window{safe_class}">
{_copy_icon_button(copy_label, copy_value)}
{code_lines}
</div>
"""
)
""")
def _code_copy_value(commands: str) -> str:
@@ -167,8 +163,7 @@ def _known_request_ip(request_ip: str | None = None) -> tuple[int | None, str]:
def _manual_update_panel(commands: str) -> SafeHtml:
return trusted(
f"""
return trusted(f"""
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
@@ -176,8 +171,7 @@ def _manual_update_panel(commands: str) -> SafeHtml:
</div>
{_code_window(commands, copy_label="manual update commands", class_name="manual-code")}
</div>
"""
)
""")
def _fritzbox_fields(
@@ -196,25 +190,23 @@ def _fritzbox_fields(
def _secret_notice(*, include_registration: bool = False) -> SafeHtml:
secret_items = "Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
return trusted(
f"""
secret_items = (
"Update URL, register URL, and dashboard link" if include_registration else "Update URL and dashboard link"
)
return trusted(f"""
<div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Hostname and CNAME target can be shared.
<strong>Secret:</strong> {escape(secret_items)} can update or manage this hostname.
Secrets are hidden on screen. Copy still copies the real value.
</div>
"""
)
""")
def _message_band(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
return trusted(f"""
<div class="alert alert-warning mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
""")
+3 -1
View File
@@ -17,7 +17,9 @@ def parse_form(body: bytes) -> dict[str, list[str]]:
try:
return urllib.parse.parse_qs(decoded, keep_blank_values=True, max_num_fields=MAX_FORM_FIELDS)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields") from exc
raise HTTPException(
status_code=400, detail=f"form must not contain more than {MAX_FORM_FIELDS} fields"
) from exc
async def request_form(request: Request, settings: DdnsSettings) -> dict[str, list[str]]:
+15 -11
View File
@@ -18,8 +18,7 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
{_svg_icon("sun", "theme-icon theme-icon-sun")}
</button>
"""
return trusted(
f"""
return trusted(f"""
<a class="skip-link" href="#main">Skip to content</a>
<nav class="navbar navbar-expand-sm site-nav" aria-label="Main navigation">
<div class="container">
@@ -31,13 +30,11 @@ def _top_nav(label: str | None = None, links: SafeHtml | None = None) -> SafeHtm
</div>
</div>
</nav>
"""
)
""")
def _site_footer() -> SafeHtml:
return trusted(
f"""
return trusted(f"""
<footer class="site-footer border-top">
<div class="container footer-inner">
<div class="footer-made">Made with <span aria-label="love">❤️</span> by karl</div>
@@ -65,8 +62,7 @@ def _site_footer() -> SafeHtml:
</nav>
</div>
</footer>
"""
)
""")
def _page(
@@ -85,7 +81,11 @@ def _page(
raise TypeError("body must be a SafeHtml fragment built by a template helper")
extra_styles_html = extra_styles or trusted("")
extra_scripts_html = extra_scripts or trusted("")
preload_logo_html = trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">') if preload_logo else trusted("")
preload_logo_html = (
trusted('<link rel="preload" as="image" href="/logo.webp?v=3" type="image/webp">')
if preload_logo
else trusted("")
)
canonical_html = trusted(f'<link rel="canonical" href="{attr(canonical_url)}">') if canonical_url else trusted("")
social_url = canonical_url or ""
social_image = image_url or ""
@@ -93,14 +93,18 @@ def _page(
if social_url:
social_card = "summary_large_image" if social_image else "summary"
social_image_alt = image_alt or title
social_image_html = f"""
social_image_html = (
f"""
<meta property="og:image" content="{attr(social_image)}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{attr(social_image_alt)}">
<meta name="twitter:image" content="{attr(social_image)}">
<meta name="twitter:image:alt" content="{attr(social_image_alt)}">
""" if social_image else ""
"""
if social_image
else ""
)
social_html = trusted(f"""
<meta property="og:type" content="website">
<meta property="og:title" content="{attr(title)}">
-1
View File
@@ -47,7 +47,6 @@ class RequestBodyLimitMiddleware:
await response(scope, receive, send)
def install_security_middleware(app: FastAPI, settings: DdnsSettings, store: DdnsStore) -> None:
@app.middleware("http")
async def security_middleware(request: Request, call_next):
+35 -22
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime
from urllib.parse import urlencode
@@ -30,13 +31,14 @@ def _admin_secret_panel(title: str, rows: list[SafeHtml]) -> SafeHtml:
</section>
""")
def _render_admin_page(
service: DdnsService,
settings: DdnsSettings,
accounts: list[dict[str, str | int | None]],
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
update_events: list[dict[str, str | int | None]],
accounts: Sequence[Mapping[str, object]],
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
update_events: Sequence[Mapping[str, object]],
service_metrics: dict[str, float],
table_counts: dict[str, int],
table_offsets: dict[str, int],
@@ -55,8 +57,16 @@ def _render_admin_page(
accounts_count = escape(table_counts.get("accounts", len(accounts)))
event_count = escape(table_counts.get("update_events", len(update_events)))
metrics_html = _metrics_panel(service_metrics)
account_pager = _server_page_links(admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0))
event_pager = _server_page_links(admin_path, "update_events_offset", table_offsets.get("update_events", 0), table_limit, table_counts.get("update_events", 0))
account_pager = _server_page_links(
admin_path, "accounts_offset", table_offsets.get("accounts", 0), table_limit, table_counts.get("accounts", 0)
)
event_pager = _server_page_links(
admin_path,
"update_events_offset",
table_offsets.get("update_events", 0),
table_limit,
table_counts.get("update_events", 0),
)
cleanup_panel = _cleanup_panel(settings, cleanup_runs, scheduled_tasks, safe_admin_path, csrf)
totals_html = _totals_strip(table_counts, service_metrics)
return _page(
@@ -142,10 +152,7 @@ def _totals_strip(table_counts: dict[str, int], metrics: dict[str, float]) -> Sa
]
return trusted(
'<div class="admin-total-strip" aria-label="Service totals">'
+ "".join(
f'<span><strong>{escape(value)}</strong>{escape(label)}</span>'
for label, value in items
)
+ "".join(f"<span><strong>{escape(value)}</strong>{escape(label)}</span>" for label, value in items)
+ "</div>"
)
@@ -181,12 +188,15 @@ def _server_page_links(admin_path: str, offset_name: str, offset: int, limit: in
def _cleanup_panel(
settings: DdnsSettings,
cleanup_runs: list[dict[str, str | int]],
scheduled_tasks: list[dict[str, str | int | None]],
cleanup_runs: Sequence[Mapping[str, object]],
scheduled_tasks: Sequence[Mapping[str, object]],
safe_admin_path: str,
csrf: str,
) -> SafeHtml:
task = next((item for item in scheduled_tasks if item.get("name") == "cleanup"), scheduled_tasks[0] if scheduled_tasks else {})
task = next(
(item for item in scheduled_tasks if item.get("name") == "cleanup"),
scheduled_tasks[0] if scheduled_tasks else {},
)
last_run = task.get("last_finished_at") or (cleanup_runs[0].get("created_at") if cleanup_runs else None)
next_run = task.get("next_run_at")
status = task.get("last_status") or "pending"
@@ -235,14 +245,14 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
("Blocked requests", _metric_int(metrics.get("rate_limit_hits_total", 0))),
("Average update time", f"{average_latency:.1f} ms"),
("Last update time", f"{metrics.get('last_update_latency_ms', 0):.1f} ms"),
("Cleanup ok/errors", f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}"),
(
"Cleanup ok/errors",
f"{_metric_int(metrics.get('cleanup_success_total', 0))}/{_metric_int(metrics.get('cleanup_errors_total', 0))}",
),
]
cards = "\n".join(
f"""
cards = "\n".join(f"""
<span><strong>{escape(value)}</strong>{escape(label)}</span>
"""
for label, value in items
)
""" for label, value in items)
return trusted(f"""
<div class="admin-metric-strip" aria-label="Service metrics">
{cards}
@@ -250,13 +260,16 @@ def _metrics_panel(metrics: dict[str, float]) -> SafeHtml:
""")
def _metric_int(value: float) -> str:
return str(int(value))
def _metric_int(value: object) -> str:
try:
return str(int(float(str(value))))
except ValueError:
return "0"
def _format_interval(seconds: object) -> str:
try:
value = int(seconds)
value = int(str(seconds))
except (TypeError, ValueError):
return str(seconds)
if value % 3600 == 0:
+3 -2
View File
@@ -1,12 +1,13 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
from .components import _event_label
from .html_utils import attr, escape
def _account_row(account: dict[str, str | int | None], csrf: str = "", admin_path: str = "/admin") -> str:
def _account_row(account: Mapping[str, object], csrf: str = "", admin_path: str = "/admin") -> str:
raw_hostname = str(account["hostname"])
hostname = escape(raw_hostname)
hostname_attr = attr(raw_hostname)
@@ -77,7 +78,7 @@ def _admin_table_pagination() -> str:
"""
def _event_row(event: dict[str, str | int | None]) -> str:
def _event_row(event: Mapping[str, object]) -> str:
created_raw = str(event.get("created_at") or "-")
hostname_raw = str(event.get("hostname") or "-")
detail_raw = str(event.get("detail") or "-")
+21 -14
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from ..services.ddns import DdnsService
from ..settings import DdnsSettings
from .components import (
@@ -19,7 +21,7 @@ def _render_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
@@ -37,11 +39,19 @@ def _render_management_page(
fritzbox_fields = _fritzbox_fields(update_url, hostname)
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
manual_commands = _manual_update_commands(update_url, request_ip)
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)) or """
history_rows = (
"\n".join(
_user_event_row(event)
for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 10)
)
or """
<tr><td colspan="6" class="empty">No router updates yet.</td></tr>
"""
)
message_html = _management_message(message)
dashboard_password_panel = _dashboard_password_panel(management_slug, csrf_token, bool(account.get("dashboard_password_hash")))
dashboard_password_panel = _dashboard_password_panel(
management_slug, csrf_token, bool(account.get("dashboard_password_hash"))
)
return _page(
"KarlDNS management",
trusted(f"""
@@ -143,11 +153,12 @@ def _render_management_page(
extra_styles=trusted('<link href="/admin.css?v=5" rel="stylesheet">'),
)
def _render_pending_management_page(
service: DdnsService,
settings: DdnsSettings,
management_slug: str,
account: dict[str, str | int | None],
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
request_ip: str | None = None,
@@ -224,13 +235,11 @@ def _render_pending_management_page(
def _management_message(message: str | None) -> SafeHtml:
if not message:
return trusted("")
return trusted(
f"""
return trusted(f"""
<div class="alert alert-info mb-0 rounded-0 border-start-0 border-end-0" role="status">
<div class="container">{escape(message)}</div>
</div>
"""
)
""")
def _dashboard_password_panel(management_slug: str, csrf_token: str, password_enabled: bool) -> SafeHtml:
@@ -254,8 +263,7 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
<input class="form-control" id="dashboard-current-password" name="current_dashboard_password" type="password" autocomplete="current-password" required>
</div>
"""
return trusted(
f"""
return trusted(f"""
<section class="dashboard-section">
<div class="container">
<div class="dashboard-settings-grid">
@@ -285,13 +293,12 @@ def _dashboard_password_panel(management_slug: str, csrf_token: str, password_en
</div>
</div>
</section>
"""
)
""")
def _render_management_unlock_page(
management_slug: str,
account: dict[str, str | int | None],
account: Mapping[str, object],
csrf_token: str,
message: str | None = None,
) -> str:
@@ -333,7 +340,7 @@ def _render_management_unlock_page(
)
def _user_event_row(event: dict[str, str | int | None]) -> str:
def _user_event_row(event: Mapping[str, object]) -> str:
created = escape(event.get("created_at") or "-")
raw_status = str(event.get("status") or "")
status = escape(_event_label(raw_status))
+4 -1
View File
@@ -99,6 +99,7 @@ def _render_public_home(
},
)
def _render_privacy_page(settings: DdnsSettings) -> str:
base_url = settings.public_base_url.rstrip("/")
description = "Privacy and legal information for KarlDNS, including stored hostnames, update links, IP history, and cleanup behavior."
@@ -159,7 +160,9 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
base_url = settings.public_base_url.rstrip("/")
example_ipv4, _ = _example_update_ips(request_ip)
create_example = f"curl -sS -X POST {base_url}/api/v1/hostnames"
register_example = f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'"
register_example = (
f"curl -sS -X POST '{base_url}/api/v1/router/register/<karl_dashboard_secret>?myip={example_ipv4}'"
)
update_example = f"curl -sS '{base_url}/api/v1/router/update/<karl_update_secret>?myip={example_ipv4}'"
status_example = f"curl -sS '{base_url}/api/v1/hostnames/<karl_dashboard_secret>?history_limit=10'"
rotate_example = f"curl -sS -X POST {base_url}/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url"
+2 -4
View File
@@ -8,8 +8,7 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
base_url = base_url.rstrip("/")
system_install = f"curl -fsSL {base_url}/install.sh | sudo sh"
system_uninstall = f"curl -fsSL {base_url}/uninstall.sh | sudo sh"
return trusted(
f"""
return trusted(f"""
<section class="section" id="linux-installer">
<div class="container narrow">
<div class="text-center mb-4">
@@ -31,5 +30,4 @@ def _linux_installer_section(base_url: str) -> SafeHtml:
</div>
</div>
</section>
"""
)
""")