from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path from pydantic import BaseModel, Field, field_validator class DdnsSettings(BaseModel): database_path: Path = Field(default=Path("ddns.sqlite3")) shared_secret: str = Field(default="") admin_password: str = Field(default="") allowed_hosts: set[str] = Field(default_factory=set) public_base_url: str = Field(default="http://localhost:8080") hostname_suffix: str = Field(default="") verification_prefix: str = Field(default="_router_dyndns-ddns") session_secret: str = Field(default="") require_invite: bool = Field(default=False) max_hostnames_per_user: int = Field(default=5, ge=1, le=100) rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000) trusted_proxy_ips: set[str] = Field(default_factory=set) 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_challenge_hours: int = Field(default=72, ge=1, le=24 * 365) 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("/") @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") 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"), hostname_suffix=os.getenv("DDNS_HOSTNAME_SUFFIX", ""), verification_prefix=os.getenv("DDNS_VERIFICATION_PREFIX", "_router_dyndns-ddns"), session_secret=os.getenv("DDNS_SESSION_SECRET", os.getenv("DDNS_ADMIN_PASSWORD", "")), require_invite=os.getenv("DDNS_REQUIRE_INVITE", "0").strip().lower() not in {"0", "false", "no"}, max_hostnames_per_user=_env_int("DDNS_MAX_HOSTNAMES_PER_USER", 5), rate_limit_per_minute=_env_int("DDNS_RATE_LIMIT_PER_MINUTE", 60), 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), ttl=_env_int("DDNS_TTL", 60), ) @dataclass(slots=True) class UpdateResult: hostname: str ipv4: str | None ipv6: str | None changed: bool update_ipv4: bool = True update_ipv6: bool = True @property 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