Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user