This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

384 lines
14 KiB
Python

from __future__ import annotations
import ipaddress
import logging
import re
import secrets
import sqlite3
import threading
import time
import urllib.parse
from collections.abc import Mapping
from dataclasses import dataclass
from fastapi import HTTPException, Request
from ..dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
from ..event_statuses import DNS_DELETE_FAILED, DNS_FAILED, DNS_RECONCILE_FAILED, UNCHANGED, UPDATED
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"
@dataclass(slots=True)
class UpdateOutcome:
result: UpdateResult
source_ip: str
@dataclass(frozen=True, slots=True)
class UpdateIpParams:
myip: str | None = None
ipaddr: str | None = None
myipv6: str | None = None
ip6addr: str | None = None
@property
def initial_plan_args(self) -> tuple[str | None, str | None, str | None, str | None]:
return self.myip, self.ipaddr, self.myipv6, self.ip6addr
def with_body_values(self, body_values: Mapping[str, str]) -> UpdateIpParams:
return UpdateIpParams(
myip=self.myip or body_values.get("myip"),
ipaddr=self.ipaddr or body_values.get("ipaddr"),
myipv6=self.myipv6 or body_values.get("myipv6"),
ip6addr=self.ip6addr or body_values.get("ip6addr"),
)
@dataclass(frozen=True, slots=True)
class InitialDnsPlan:
ipv4: str | None
ipv6: str | None
update_ipv4: bool
update_ipv6: bool
detail: 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 = [threading.Lock() for _ in range(self._LOCK_STRIPES)]
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):
try:
return self.store.create_account(self.random_managed_hostname(), accepted=False)
except sqlite3.IntegrityError:
continue
raise HTTPException(status_code=500, detail="could not allocate hostname")
def delete_account(self, hostname: str, source: str) -> None:
try:
delete_dns_records(hostname, self.settings)
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)
def publish_initial_dns(
self,
request: Request,
hostname: str,
ip_params: UpdateIpParams,
) -> bool:
if not dns_backend_configured(self.settings):
return False
source_ip = client_ip(request, self.settings)
plan = _initial_dns_plan(source_ip, *ip_params.initial_plan_args)
lock = self._lock_for(hostname)
with lock:
planned = self.store.preview_update(
hostname,
plan.ipv4,
plan.ipv6,
update_ipv4=plan.update_ipv4,
update_ipv6=plan.update_ipv6,
)
if planned.changed:
try:
publish_dns(planned, self.settings)
except DnsReconciliationError as exc:
LOGGER.warning(
"Initial 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,
plan.ipv4,
plan.ipv6,
DNS_RECONCILE_FAILED,
f"{type(exc).__name__}: {exc}",
source_ip,
)
raise
except DnsProviderError as exc:
LOGGER.warning(
"Initial 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,
plan.ipv4,
plan.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, plan.detail, source_ip)
return True
def apply_update(
self,
request: Request,
hostname: str,
ip_params: UpdateIpParams,
) -> 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)
try:
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_ip,
*ip_params.initial_plan_args,
)
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: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/")
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: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(str(account["management_slug"]))
return f"{base}/m/{slug}"
def registration_url(self, account: Mapping[str, object]) -> str:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(str(account["management_slug"]))
return f"{base}/api/v1/router/register/{slug}"
def random_managed_hostname(self) -> str:
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
if not suffix:
raise HTTPException(status_code=500, detail="DDNS_HOSTNAME_SUFFIX is required for managed hostnames")
return f"{secrets.token_hex(6)}.{suffix}"
def hostname_is_managed(self, hostname: str) -> bool:
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
if not suffix:
return True
normalized = hostname.strip().lower().rstrip(".")
return normalized == suffix or normalized.endswith(f".{suffix}")
def _lock_for(self, hostname: str) -> threading.Lock:
return self._locks[hash(hostname) % self._LOCK_STRIPES]
def normalize_ip(value: str | None, version: int | None = None) -> str | None:
ip = _optional_ip(value)
if ip is None or (version and ip.version != version):
return None
return str(ip)
def parse_update_ips(
source_ip: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> tuple[str | None, str | None, bool, bool]:
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:
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 _initial_dns_plan(
source_ip: str,
myip: str | None,
ipaddr: str | None,
myipv6: str | None,
ip6addr: str | None,
) -> InitialDnsPlan:
source_fallback = source_ip if _is_global_ip(source_ip) else ""
if not _has_supplied_ip(myip, ipaddr, myipv6, ip6addr) and not source_fallback:
return InitialDnsPlan(
ipv4=INITIAL_PLACEHOLDER_IPV4,
ipv6=None,
update_ipv4=True,
update_ipv6=False,
detail="initial placeholder dns registered",
)
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
source_fallback,
myip,
ipaddr,
myipv6,
ip6addr,
)
return InitialDnsPlan(
ipv4=ipv4,
ipv6=ipv6,
update_ipv4=update_ipv4,
update_ipv6=update_ipv6,
detail="initial dns registered",
)
def _has_supplied_ip(*values: str | None) -> bool:
return any(value and value.strip() for value in values)
def _is_global_ip(value: str | None) -> bool:
if not value:
return False
try:
return ipaddress.ip_address(value.strip()).is_global
except ValueError:
return False
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
ip_value = _required_ip_param(name, value, version)
if selected is None:
selected = ip_value
return selected
def _optional_ip(value: str | None) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
if not value or not value.strip():
return None
try:
return ipaddress.ip_address(value.strip())
except ValueError:
return None
def _required_ip_param(name: str, value: str, version: int) -> str:
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")
return str(ip)
def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool = True) -> str:
hostname = value.strip().lower().rstrip(".")
suffix = settings.hostname_suffix.strip().lower().strip(".")
if expand_suffix and suffix and "." not in hostname:
hostname = f"{hostname}.{suffix}"
if not _valid_domain_name(hostname):
return ""
return hostname
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))