Archived
152 lines
5.7 KiB
Python
152 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",
|
|
"/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")
|