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")