Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+81 -34
View File
@@ -6,16 +6,17 @@ import re
import secrets
import sqlite3
import threading
import time
import urllib.parse
from dataclasses import dataclass
from fastapi import HTTPException, Request
from .ddns_dns import DnsProviderError, delete_dns_records, dns_backend_configured, publish_dns
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
from .ddns_models import DdnsSettings, UpdateResult
from .ddns_security import client_ip
from .ddns_store import DdnsStore
from .event_statuses import DNS_DELETE_FAILED, DNS_FAILED, UNCHANGED, UPDATED
from .event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED
LOGGER = logging.getLogger(__name__)
@@ -121,36 +122,75 @@ class DdnsService:
myipv6: str | None,
ip6addr: str | None,
) -> UpdateOutcome:
started_at = time.perf_counter()
is_success = False
is_dns_failure = False
is_reconcile_failure = False
is_bad_ip = False
source_ip = client_ip(request, self.settings)
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_ip,
myip,
ipaddr,
myipv6,
ip6addr,
)
lock = self._lock_for(hostname)
with lock:
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)
except DnsProviderError as exc:
LOGGER.warning(
"DNS publish failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
hostname,
planned.update_ipv4,
planned.update_ipv6,
type(exc).__name__,
)
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, "ok", source_ip)
else:
result = planned
self.store.log_update_rollup(hostname, result.ipv4, result.ipv6, UNCHANGED, "ok", source_ip)
return UpdateOutcome(result=result, source_ip=source_ip)
try:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_ip,
myip,
ipaddr,
myipv6,
ip6addr,
)
lock = self._lock_for(hostname)
with lock:
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)
except DnsReconciliationError as exc:
is_reconcile_failure = True
LOGGER.warning(
"DNS reconciliation failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
hostname,
planned.update_ipv4,
planned.update_ipv6,
type(exc).__name__,
)
self.store.log_update_event(
hostname,
ipv4,
ipv6,
DNS_RECONCILE_FAILED,
f"{type(exc).__name__}: {exc}",
source_ip,
)
raise
except DnsProviderError as exc:
is_dns_failure = True
LOGGER.warning(
"DNS publish failed for hostname=%s update_ipv4=%s update_ipv6=%s error_type=%s",
hostname,
planned.update_ipv4,
planned.update_ipv6,
type(exc).__name__,
)
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
self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "ok", source_ip)
else:
result = planned
is_success = True
self.store.log_update_rollup(hostname, result.ipv4, result.ipv6, UNCHANGED, "ok", source_ip)
return UpdateOutcome(result=result, source_ip=source_ip)
except BadIpError:
is_bad_ip = True
raise
finally:
elapsed_ms = (time.perf_counter() - started_at) * 1000
self.store.record_update_metrics(
elapsed_ms,
is_success=is_success,
is_dns_failure=is_dns_failure,
is_reconcile_failure=is_reconcile_failure,
is_bad_ip=is_bad_ip,
)
def fritz_update_url(self, account: dict[str, str]) -> str:
base = self.settings.public_base_url.rstrip("/")
@@ -275,14 +315,21 @@ def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool =
suffix = settings.hostname_suffix.strip().lower().strip(".")
if expand_suffix and suffix and "." not in hostname:
hostname = f"{hostname}.{suffix}"
label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
if not re.fullmatch(rf"{label}(\.{label})+", hostname):
if not _valid_domain_name(hostname):
return ""
return hostname
def normalize_domain(value: str) -> str:
return normalize_hostname(value, DdnsSettings(), expand_suffix=False)
domain = value.strip().lower().rstrip(".")
if not _valid_domain_name(domain):
return ""
return domain
def _valid_domain_name(value: str) -> bool:
label = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
return bool(re.fullmatch(rf"{label}(\.{label})+", value))
def dns_txt_contains(name: str, expected: str) -> bool: