Build KarlDNS self-hosted DynDNS service

This commit is contained in:
StasonJatham committed 2026-05-20 23:55:27 +02:00
1 parent 45bc38d950
commit ca92dc211a
65 files changed
+9200 -3570

No files matched your search

+91 -40
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import ipaddress
import logging
import re
import secrets
import sqlite3
@@ -10,10 +11,13 @@ from dataclasses import dataclass
from fastapi import HTTPException, Request
from .ddns_dns import delete_dns_records, dns_backend_configured, publish_dns
from .ddns_dns import DnsProviderError, 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
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
@@ -22,12 +26,17 @@ class UpdateOutcome:
source_ip: str
class BadIpError(ValueError):
"""Raised when a supplied DynDNS IP parameter is invalid."""
class DdnsService:
_LOCK_STRIPES = 256
def __init__(self, settings: DdnsSettings, store: DdnsStore) -> None:
self.settings = settings
self.store = store
self._locks_guard = threading.Lock()
self._locks: dict[str, threading.Lock] = {}
self._locks = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
def create_managed_account(self, username: str | None = None) -> dict[str, str]:
if not self.settings.hostname_suffix:
@@ -44,33 +53,39 @@ class DdnsService:
hostname: str,
claim_secret: str,
username: str | None = None,
owner_hostname: str | None = None,
) -> dict[str, str]:
normalized = normalize_hostname(hostname, self.settings, expand_suffix=False)
if not normalized:
raise HTTPException(status_code=400, detail="valid custom hostname is required")
if not self.hostname_is_publishable(normalized):
raise HTTPException(status_code=403, detail="hostname is outside configured DNS publishing zones")
if not self.best_verified_parent(normalized, claim_secret):
if not self.best_verified_parent(normalized, claim_secret, owner_hostname=owner_hostname):
raise HTTPException(status_code=403, detail="verify the parent domain before creating this hostname")
try:
return self.store.create_account(normalized, username)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="hostname already exists") from exc
def create_domain_challenge(self, domain: str) -> dict[str, str]:
def create_domain_challenge(self, domain: str, owner_hostname: str | None = None) -> dict[str, str]:
normalized = normalize_domain(domain)
if not normalized:
raise HTTPException(status_code=400, detail="valid domain is required")
existing = self.store.get_domain_challenge(normalized)
if existing and existing.get("verified_at"):
raise HTTPException(status_code=409, detail="domain is already verified")
return self.store.create_domain_challenge(normalized)
return self.store.create_domain_challenge(normalized, owner_hostname=owner_hostname)
def verify_domain(self, domain: str, claim_secret: str) -> tuple[bool, dict[str, str | None]]:
def verify_domain(
self,
domain: str,
claim_secret: str,
owner_hostname: str | None = None,
) -> tuple[bool, dict[str, str | None]]:
normalized = normalize_domain(domain)
if not normalized:
raise HTTPException(status_code=400, detail="valid domain is required")
challenge = self.store.get_domain_challenge(normalized, claim_secret=claim_secret)
challenge = self.store.get_domain_challenge(normalized, claim_secret=claim_secret, owner_hostname=owner_hostname)
if not challenge:
raise HTTPException(status_code=404, detail="domain challenge not found")
found = dns_txt_contains(self.verification_name(normalized), str(challenge["token"]))
@@ -78,18 +93,22 @@ class DdnsService:
challenge = dict(challenge)
challenge["claim_secret"] = claim_secret
return False, challenge
verified = self.store.verify_domain_challenge(normalized)
verified = self.store.verify_domain_challenge(normalized, claim_secret, owner_hostname=owner_hostname)
if verified is None:
raise HTTPException(status_code=404, detail="domain challenge not found")
verified = dict(verified)
verified["claim_secret"] = claim_secret
return True, verified
def delete_account(self, hostname: str, source: str) -> None:
try:
delete_dns_records(hostname, self.settings)
except Exception as exc:
self.store.log_update_event(hostname, None, None, "dns_delete_failed", f"{type(exc).__name__}: {exc}", source)
except DnsProviderError as exc:
LOGGER.warning(
"DNS delete failed for hostname=%s source=%s error_type=%s",
hostname,
source,
type(exc).__name__,
)
self.store.log_update_event(hostname, None, None, DNS_DELETE_FAILED, f"{type(exc).__name__}: {exc}", source)
raise
self.store.delete_account(hostname)
@@ -102,19 +121,35 @@ class DdnsService:
myipv6: str | None,
ip6addr: str | None,
) -> UpdateOutcome:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(request, myip, ipaddr, myipv6, ip6addr)
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 Exception as exc:
self.store.log_update_event(hostname, ipv4, ipv6, "dns_failed", f"{type(exc).__name__}: {exc}", source_ip)
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.upsert(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
self.store.log_update_event(hostname, result.ipv4, result.ipv6, "updated" if result.changed else "unchanged", "ok", source_ip)
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)
def fritz_update_url(self, account: dict[str, str]) -> str:
@@ -149,13 +184,18 @@ class DdnsService:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
return f"{secrets.token_hex(6)}.{suffix}"
def best_verified_parent(self, hostname: str, claim_secret: str | None = None) -> str | None:
def best_verified_parent(
self,
hostname: str,
claim_secret: str | None = None,
owner_hostname: str | None = None,
) -> str | None:
labels = hostname.split(".")
for index in range(1, len(labels) - 1):
candidate = ".".join(labels[index:])
if self.store.is_domain_verified(candidate, claim_secret):
if self.store.is_domain_verified(candidate, claim_secret, owner_hostname=owner_hostname):
return candidate
if self.store.is_domain_verified(hostname, claim_secret):
if self.store.is_domain_verified(hostname, claim_secret, owner_hostname=owner_hostname):
return hostname
return None
@@ -178,19 +218,14 @@ class DdnsService:
return bool(zones) and any(hostname == zone or hostname.endswith(f".{zone}") for zone in zones)
def _lock_for(self, hostname: str) -> threading.Lock:
with self._locks_guard:
lock = self._locks.get(hostname)
if lock is None:
lock = threading.Lock()
self._locks[hostname] = lock
return lock
return self._locks[hash(hostname) % self._LOCK_STRIPES]
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
if not value:
if not value or not value.strip():
return None
try:
ip = ipaddress.ip_address(value)
ip = ipaddress.ip_address(value.strip())
except ValueError:
return None
if version and ip.version != version:
@@ -199,27 +234,42 @@ def normalize_ip(value: str | None, version: int | None = None) -> str | None:
def parse_update_ips(
request: Request,
source_ip: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> tuple[str | None, str | None, bool, bool]:
raw_ipv4 = myip if myip is not None else ipaddr
raw_ipv6 = myipv6 if myipv6 is not None else ip6addr
update_ipv4 = raw_ipv4 is not None
update_ipv6 = raw_ipv6 is not None
ipv4 = normalize_ip(raw_ipv4, version=4)
ipv6 = normalize_ip(raw_ipv6, version=6)
ipv4 = _first_valid_ip((("myip", myip), ("ipaddr", ipaddr)), version=4)
ipv6 = _first_valid_ip((("myipv6", myipv6), ("ip6addr", ip6addr)), version=6)
update_ipv4 = ipv4 is not None
update_ipv6 = ipv6 is not None
if not update_ipv4 and not update_ipv6:
peer = request.client.host if request.client else None
ipv4 = normalize_ip(peer, version=4)
ipv6 = normalize_ip(peer, version=6)
ipv4 = normalize_ip(source_ip, version=4)
ipv6 = normalize_ip(source_ip, version=6)
update_ipv4 = ipv4 is not None
update_ipv6 = ipv6 is not None
if not update_ipv4 and not update_ipv6:
raise BadIpError("source IP is not a valid IPv4 or IPv6 address")
return ipv4, ipv6, update_ipv4, update_ipv6
def _first_valid_ip(params: tuple[tuple[str, str | None], ...], version: int) -> str | None:
selected = None
for name, value in params:
if value is None or not value.strip():
continue
try:
ip = ipaddress.ip_address(value.strip())
except ValueError as exc:
raise BadIpError(f"{name} must be a valid IPv{version} address") from exc
if ip.version != version:
raise BadIpError(f"{name} must be an IPv{version} address")
if selected is None:
selected = str(ip)
return selected
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
hostname = value.strip().lower().rstrip(".")
suffix = settings.hostname_suffix.strip().lower().strip(".")
@@ -237,10 +287,11 @@ def normalize_domain(value: str) -> str:
def dns_txt_contains(name: str, expected: str) -> bool:
try:
import dns.exception
import dns.resolver
answers = dns.resolver.resolve(name, "TXT", lifetime=5)
except Exception:
except dns.exception.DNSException:
return False
for answer in answers:
for item in answer.strings: