Archived
Preserve the GitHub history and the newer deployed source from ct_134_karldns. Exclude runtime data, credentials, AppleDouble files and local backup copies. Make Gitea the canonical source for documentation, package metadata and project links.
153 lines
5.7 KiB
Python
153 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import urllib.parse
|
|
from pathlib import Path
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
_RESERVED_PUBLIC_PATHS = {
|
|
"/admin.css",
|
|
"/admin-tables.js",
|
|
"/api",
|
|
"/app.js",
|
|
"/clipboard.js",
|
|
"/docs",
|
|
"/favicon.ico",
|
|
"/healthz",
|
|
"/install.sh",
|
|
"/llms.txt",
|
|
"/bootstrap-subset.css",
|
|
"/logo.png",
|
|
"/logo-nav.webp",
|
|
"/logo.webp",
|
|
"/m",
|
|
"/openapi.json",
|
|
"/privacy",
|
|
"/redoc",
|
|
"/robots.txt",
|
|
"/sitemap.xml",
|
|
"/site.webmanifest",
|
|
"/social-preview.png",
|
|
"/social-preview.webp",
|
|
"/theme.js",
|
|
"/uninstall.sh",
|
|
"/visitor-ip.js",
|
|
}
|
|
|
|
|
|
class DdnsSettings(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
database_path: Path = Field(default=Path("ddns.sqlite3"))
|
|
admin_password: str = Field(default="")
|
|
admin_path: str = Field(default="/admin")
|
|
public_base_url: str = Field(default="http://localhost:8080")
|
|
trusted_hosts: set[str] = Field(default_factory=set)
|
|
hostname_suffix: str = Field(default="")
|
|
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
|
|
admin_rate_limit_per_minute: int = Field(default=20, ge=1, le=10_000)
|
|
max_request_body_bytes: int = Field(default=16_384, ge=1024, le=1_048_576)
|
|
trusted_proxy_ips: set[str] = Field(default_factory=set)
|
|
trust_private_proxy_headers: bool = Field(default=True)
|
|
rfc2136_server: str | None = None
|
|
rfc2136_zone: str | None = None
|
|
rfc2136_key_name: str | None = None
|
|
rfc2136_key_secret: str | None = None
|
|
dns_provider: str = Field(default="")
|
|
dns_zones: set[str] = Field(default_factory=set)
|
|
require_dns_provider: bool = Field(default=False)
|
|
cloudflare_api_token: str = Field(default="")
|
|
cloudflare_zone_id: str = Field(default="")
|
|
cleanup_pending_account_hours: int = Field(default=24, ge=1, le=24 * 30)
|
|
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)
|
|
dashboard_cookie_secret: str = Field(default="")
|
|
redacted_access_log: bool = Field(default=False)
|
|
ttl: int = Field(default=60, ge=60, le=86400)
|
|
|
|
@field_validator("public_base_url")
|
|
@classmethod
|
|
def validate_public_base_url(_cls, value: str) -> str:
|
|
if not value.startswith(("http://", "https://")):
|
|
raise ValueError("public_base_url must start with http:// or https://")
|
|
return value.rstrip("/")
|
|
|
|
@field_validator("trusted_hosts")
|
|
@classmethod
|
|
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 _has_valid_admin_path_shape(path):
|
|
raise ValueError("admin_path must start with / and must not be / or end with /")
|
|
if not _has_url_safe_path_chars(path):
|
|
raise ValueError("admin_path may only contain URL-safe path characters")
|
|
if _is_reserved_admin_path(path):
|
|
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)
|
|
public_host = urllib.parse.urlparse(self.public_base_url).hostname
|
|
if public_host:
|
|
hosts.add(public_host.lower())
|
|
if not self.require_dns_provider:
|
|
hosts.update({"testserver", "localhost", "127.0.0.1", "::1"})
|
|
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
|
|
_validate_launch_secrets(self.admin_password, self.dashboard_cookie_secret)
|
|
_validate_launch_hostname_suffix(self.hostname_suffix)
|
|
_validate_launch_trusted_hosts(self.effective_trusted_hosts)
|
|
|
|
@classmethod
|
|
def from_env(_cls) -> DdnsSettings:
|
|
from .config import settings_from_env
|
|
|
|
return settings_from_env()
|
|
|
|
|
|
def _has_valid_admin_path_shape(path: str) -> bool:
|
|
return path.startswith("/") and path != "/" and not path.endswith("/")
|
|
|
|
|
|
def _has_url_safe_path_chars(path: str) -> bool:
|
|
return re.fullmatch(r"/[A-Za-z0-9._~/-]+", path) is not None
|
|
|
|
|
|
def _is_reserved_admin_path(path: str) -> bool:
|
|
return any(path == reserved or path.startswith(f"{reserved}/") for reserved in _RESERVED_PUBLIC_PATHS)
|
|
|
|
|
|
def _validate_launch_secrets(admin_password: str, dashboard_cookie_secret: str) -> None:
|
|
if len(admin_password) < 16:
|
|
raise RuntimeError("DDNS_ADMIN_PASSWORD must be at least 16 characters when DDNS_REQUIRE_DNS_PROVIDER=1")
|
|
if len(dashboard_cookie_secret or admin_password) < 32:
|
|
raise RuntimeError(
|
|
"DDNS_DASHBOARD_COOKIE_SECRET or DDNS_ADMIN_PASSWORD must be at least 32 characters "
|
|
"for dashboard cookie signing when DDNS_REQUIRE_DNS_PROVIDER=1"
|
|
)
|
|
|
|
|
|
def _validate_launch_hostname_suffix(hostname_suffix: str) -> None:
|
|
if not hostname_suffix:
|
|
raise RuntimeError("DDNS_HOSTNAME_SUFFIX is required when DDNS_REQUIRE_DNS_PROVIDER=1")
|
|
|
|
|
|
def _validate_launch_trusted_hosts(effective_trusted_hosts: list[str]) -> None:
|
|
if not effective_trusted_hosts:
|
|
raise RuntimeError("At least one trusted host is required for production")
|