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

+46 -47
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
import os
import re
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
@@ -12,6 +12,7 @@ class DdnsSettings(BaseModel):
database_path: Path = Field(default=Path("ddns.sqlite3"))
shared_secret: str = Field(default="")
admin_password: str = Field(default="")
admin_path: str = Field(default="/admin")
allowed_hosts: set[str] = Field(default_factory=set)
public_base_url: str = Field(default="http://localhost:8080")
trusted_hosts: set[str] = Field(default_factory=set)
@@ -32,7 +33,10 @@ class DdnsSettings(BaseModel):
cloudflare_zone_id: str = Field(default="")
cleanup_challenge_hours: int = Field(default=72, ge=1, le=24 * 365)
cleanup_unused_account_hours: int = Field(default=1080, ge=1, le=24 * 365)
cleanup_event_history_hours: int = Field(default=24 * 180, ge=1, le=24 * 3650)
cleanup_interval_seconds: int = Field(default=3600, ge=1, le=24 * 60 * 60)
dashboard_unlock_seconds: int = Field(default=24 * 60 * 60, ge=60, le=30 * 24 * 60 * 60)
dashboard_stay_unlocked_seconds: int = Field(default=30 * 24 * 60 * 60, ge=60, le=30 * 24 * 60 * 60)
ttl: int = Field(default=60, ge=60, le=86400)
@field_validator("public_base_url")
@@ -47,6 +51,41 @@ class DdnsSettings(BaseModel):
def validate_trusted_hosts(cls, value: set[str]) -> set[str]:
return {host.strip().lower() for host in value if host.strip()}
@field_validator("admin_path")
@classmethod
def validate_admin_path(cls, value: str) -> str:
path = value.strip()
if not path.startswith("/") or path == "/" or path.endswith("/"):
raise ValueError("admin_path must start with / and must not be / or end with /")
if not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path):
raise ValueError("admin_path may only contain URL-safe path characters")
reserved_paths = {
"/accounts",
"/api",
"/app.js",
"/claim",
"/d",
"/docs",
"/favicon.ico",
"/healthz",
"/install.sh",
"/ip",
"/logo.png",
"/m",
"/magic",
"/nic",
"/openapi.json",
"/privacy",
"/redoc",
"/robots.txt",
"/site.webmanifest",
"/u",
"/uninstall.sh",
}
if any(path == reserved or path.startswith(f"{reserved}/") for reserved in reserved_paths):
raise ValueError("admin_path must not conflict with a public route")
return path
@property
def effective_trusted_hosts(self) -> list[str]:
hosts = set(self.trusted_hosts)
@@ -58,6 +97,9 @@ class DdnsSettings(BaseModel):
return sorted(hosts)
def validate_launch_ready(self) -> None:
from .dns import validate_dns_provider_settings
validate_dns_provider_settings(self)
if not self.require_dns_provider:
return
if len(self.admin_password) < 16:
@@ -69,41 +111,9 @@ class DdnsSettings(BaseModel):
@classmethod
def from_env(cls) -> DdnsSettings:
allowed = {
item.strip().lower()
for item in os.getenv("DDNS_ALLOWED_HOSTS", "").split(",")
if item.strip()
}
trusted_proxy_ips = _csv_set("DDNS_TRUSTED_PROXY_IPS")
trusted_hosts = _csv_set("DDNS_TRUSTED_HOSTS")
dns_zones = _csv_set("DDNS_DNS_ZONES")
return cls(
database_path=Path(os.getenv("DDNS_DATABASE", "ddns.sqlite3")),
shared_secret=os.getenv("DDNS_SHARED_SECRET", ""),
admin_password=os.getenv("DDNS_ADMIN_PASSWORD", os.getenv("DDNS_SHARED_SECRET", "")),
allowed_hosts=allowed,
public_base_url=os.getenv("DDNS_PUBLIC_BASE_URL", "http://localhost:8080"),
trusted_hosts=trusted_hosts,
hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""),
verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_karldns"),
rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60),
admin_rate_limit_per_minute=_env_int("DDNS_ADMIN_RATE_LIMIT_PER_MINUTE", 20),
max_request_body_bytes=_env_int("DDNS_MAX_REQUEST_BODY_BYTES", 16_384),
trusted_proxy_ips=trusted_proxy_ips,
rfc2136_server=os.getenv("DDNS_RFC2136_SERVER") or None,
rfc2136_zone=os.getenv("DDNS_RFC2136_ZONE") or None,
rfc2136_key_name=os.getenv("DDNS_RFC2136_KEY_NAME") or None,
rfc2136_key_secret=os.getenv("DDNS_RFC2136_KEY_SECRET") or None,
dns_provider=os.getenv("DDNS_DNS_PROVIDER", ""),
dns_zones=dns_zones,
require_dns_provider=os.getenv("DDNS_REQUIRE_DNS_PROVIDER", "0").strip().lower() not in {"0", "false", "no"},
cloudflare_api_token=os.getenv("DDNS_CLOUDFLARE_API_TOKEN", ""),
cloudflare_zone_id=os.getenv("DDNS_CLOUDFLARE_ZONE_ID", ""),
cleanup_challenge_hours=_env_int("DDNS_CLEANUP_CHALLENGE_HOURS", 72),
cleanup_unused_account_hours=_env_int("DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS", 1080),
cleanup_interval_seconds=_env_int("DDNS_CLEANUP_INTERVAL_SECONDS", 3600),
ttl=_env_int("DDNS_TTL", 60),
)
from .config import settings_from_env
return settings_from_env()
@dataclass(slots=True)
@@ -119,14 +129,3 @@ class UpdateResult:
def dyndns_response(self) -> str:
ip = self.ipv4 or self.ipv6 or "0.0.0.0"
return f"{'good' if self.changed else 'nochg'} {ip}"
def _csv_set(name: str) -> set[str]:
return {item.strip().lower() for item in os.getenv(name, "").split(",") if item.strip()}
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except ValueError:
return default