Archived
Simplify DDNS flow and polish UI
This commit is contained in:
1 parent
5beee0e5c6
commit
5a90bd9d87
42 files changed
+587
-1418
No files matched your search
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, dns_backend_configured, publish_dns
|
||||
from .ddns_dns import DnsProviderError, DnsReconciliationError, delete_dns_records, publish_dns
|
||||
from .ddns_models import DdnsSettings, UpdateResult
|
||||
from .ddns_security import client_ip
|
||||
from .ddns_store import DdnsStore
|
||||
@@ -49,56 +49,6 @@ class DdnsService:
|
||||
continue
|
||||
raise HTTPException(status_code=500, detail="could not allocate hostname")
|
||||
|
||||
def create_custom_account(
|
||||
self,
|
||||
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, 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, 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, owner_hostname=owner_hostname)
|
||||
|
||||
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, 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"]))
|
||||
if not found:
|
||||
challenge = dict(challenge)
|
||||
challenge["claim_secret"] = claim_secret
|
||||
return False, challenge
|
||||
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")
|
||||
return True, verified
|
||||
|
||||
def delete_account(self, hostname: str, source: str) -> None:
|
||||
try:
|
||||
delete_dns_records(hostname, self.settings)
|
||||
@@ -213,49 +163,18 @@ class DdnsService:
|
||||
slug = urllib.parse.quote(account["management_slug"])
|
||||
return f"{base}/m/{slug}"
|
||||
|
||||
def domain_claim_url(self, challenge: dict[str, str | None]) -> str:
|
||||
base = self.settings.public_base_url.rstrip("/")
|
||||
secret = urllib.parse.quote(str(challenge["claim_secret"]))
|
||||
return f"{base}/d/{secret}"
|
||||
|
||||
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 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, owner_hostname=owner_hostname):
|
||||
return candidate
|
||||
if self.store.is_domain_verified(hostname, claim_secret, owner_hostname=owner_hostname):
|
||||
return hostname
|
||||
return None
|
||||
|
||||
def verification_name(self, domain: str) -> str:
|
||||
prefix = self.settings.verification_prefix.strip().strip(".") or "_karldns"
|
||||
return f"{prefix}.{domain}"
|
||||
|
||||
def hostname_is_publishable(self, hostname: str) -> bool:
|
||||
if not dns_backend_configured(self.settings):
|
||||
return not self.settings.require_dns_provider
|
||||
zones = {
|
||||
zone.strip().lower().strip(".")
|
||||
for zone in self.settings.dns_zones
|
||||
if zone.strip()
|
||||
}
|
||||
if not zones and self.settings.rfc2136_zone:
|
||||
zones.add(self.settings.rfc2136_zone.strip().lower().strip("."))
|
||||
if not zones and self.settings.hostname_suffix:
|
||||
zones.add(self.settings.hostname_suffix.strip().lower().strip("."))
|
||||
return bool(zones) and any(hostname == zone or hostname.endswith(f".{zone}") for zone in zones)
|
||||
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]
|
||||
@@ -320,28 +239,6 @@ def normalize_hostname(value: str, settings: DdnsSettings, expand_suffix: bool =
|
||||
return hostname
|
||||
|
||||
|
||||
def normalize_domain(value: str) -> str:
|
||||
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:
|
||||
try:
|
||||
import dns.exception
|
||||
import dns.resolver
|
||||
|
||||
answers = dns.resolver.resolve(name, "TXT", lifetime=5)
|
||||
except dns.exception.DNSException:
|
||||
return False
|
||||
for answer in answers:
|
||||
for item in answer.strings:
|
||||
if item.decode("utf-8", errors="replace") == expected:
|
||||
return True
|
||||
return False
|
||||
Reference in new issue
Block a user