from __future__ import annotations import base64 import hashlib import hmac import secrets import time from collections.abc import Mapping from dataclasses import dataclass from datetime import UTC, datetime from ipaddress import ip_address, ip_network from fastapi import HTTPException, Request from .settings import DdnsSettings DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock" DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60 DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60 BEARER_SECRET_PREFIX = "karl_" PRIVATE_PROXY_NETWORKS = tuple( ip_network(network) for network in ( "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10", ) ) @dataclass(frozen=True, slots=True) class _DashboardUnlockCookieParts: timestamp: int max_age_seconds: int signature: str @dataclass(frozen=True, slots=True) class DashboardUnlockIdentity: hostname: str management_slug: str password_hash: str def payload(self, timestamp: int, max_age_seconds: int) -> bytes: return f"{self.hostname}\0{self.management_slug}\0{self.password_hash}\0{timestamp}\0{max_age_seconds}".encode() def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]: try: raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") except Exception: return None, None if ":" not in raw: return None, None username, password = raw.split(":", 1) return username, password def hash_secret(token: str) -> str: salt = secrets.token_hex(16) digest = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), salt.encode("ascii"), 200_000) return f"pbkdf2_sha256${salt}${digest.hex()}" def verify_secret(token: str, stored: str) -> bool: try: algorithm, salt, expected = stored.split("$", 2) except ValueError: return False if algorithm != "pbkdf2_sha256": return False digest = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), salt.encode("ascii"), 200_000) return hmac.compare_digest(digest.hex(), expected) def hash_lookup_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def new_bearer_secret() -> str: return f"{BEARER_SECRET_PREFIX}{secrets.token_urlsafe(32)}" def sign_dashboard_unlock_cookie( identity: DashboardUnlockIdentity, signing_secret: str, max_age_seconds: int, now: int | None = None, ) -> str: timestamp = int(time.time() if now is None else now) bounded_max_age = _bounded_dashboard_unlock_seconds(max_age_seconds) signature = _dashboard_unlock_signature( identity, signing_secret, timestamp, bounded_max_age, ) return f"v3:{timestamp}:{bounded_max_age}:{signature}" def verify_dashboard_unlock_cookie( hostname: str, management_slug: str, password_hash: str | None, signing_secret: str, cookie_value: str | None, ) -> bool: identity = _dashboard_unlock_identity(hostname, management_slug, password_hash) parts = _active_dashboard_unlock_cookie_parts(cookie_value) if identity is None or not signing_secret or parts is None: return False expected = _dashboard_unlock_signature( identity, signing_secret, parts.timestamp, parts.max_age_seconds, ) return hmac.compare_digest(parts.signature, expected) def _dashboard_unlock_identity( hostname: str, management_slug: str, password_hash: str | None, ) -> DashboardUnlockIdentity | None: if not password_hash: return None return DashboardUnlockIdentity(hostname, management_slug, password_hash) def _active_dashboard_unlock_cookie_parts(cookie_value: str | None) -> _DashboardUnlockCookieParts | None: if cookie_value is None: return None parts = _dashboard_unlock_cookie_parts(cookie_value) if parts is None: return None if _dashboard_unlock_cookie_expired(parts, int(time.time())): return None return parts def _dashboard_unlock_cookie_parts(cookie_value: str) -> _DashboardUnlockCookieParts | None: try: version, raw_timestamp, raw_max_age, signature = cookie_value.split(":", 3) timestamp = int(raw_timestamp) max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age)) except ValueError: return None if version != "v3": return None return _DashboardUnlockCookieParts(timestamp, max_age_seconds, signature) def _dashboard_unlock_cookie_expired(parts: _DashboardUnlockCookieParts, now: int) -> bool: return parts.timestamp > now + 60 or now - parts.timestamp > parts.max_age_seconds def _dashboard_unlock_signature( identity: DashboardUnlockIdentity, signing_secret: str, timestamp: int, max_age_seconds: int, ) -> str: return hmac.new(signing_secret.encode(), identity.payload(timestamp, max_age_seconds), hashlib.sha256).hexdigest() def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str: return settings.dashboard_cookie_secret or settings.admin_password def dashboard_csrf_token(management_slug: str, account: Mapping[str, object]) -> str: hostname = str(account["hostname"]) password_hash = str(account.get("dashboard_password_hash") or "") day = datetime.now(UTC).strftime("%Y-%m-%d") key = f"{management_slug}\0{password_hash}".encode() return hmac.new(key, f"dashboard:{hostname}:{day}".encode(), hashlib.sha256).hexdigest() def require_dashboard_csrf(management_slug: str, account: Mapping[str, object], supplied: str) -> None: if not supplied or not hmac.compare_digest(supplied, dashboard_csrf_token(management_slug, account)): raise HTTPException(status_code=403, detail="invalid csrf token") def _bounded_dashboard_unlock_seconds(max_age_seconds: int) -> int: return max(60, min(max_age_seconds, DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS)) def admin_csrf_token(settings: DdnsSettings) -> str: day = datetime.now(UTC).strftime("%Y-%m-%d") return hmac.new(settings.admin_password.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest() def require_admin_csrf(settings: DdnsSettings, supplied: str) -> None: if not supplied or not hmac.compare_digest(supplied, admin_csrf_token(settings)): raise HTTPException(status_code=403, detail="invalid csrf token") def client_ip(request: Request, settings: DdnsSettings | None = None) -> str: peer = request.client.host if request.client else "unknown" if _should_trust_proxy_headers(peer, settings): forwarded_ip = _client_ip_from_headers(request) if forwarded_ip: return forwarded_ip return peer def is_rate_limited_path(path: str) -> bool: return path == "/" or path.startswith(("/m/", "/api/v1/hostnames", "/api/v1/router/")) def rate_limit_bucket(path: str) -> str: for prefix in ("/m/", "/api/v1/hostnames", "/api/v1/router/"): if path.startswith(prefix): return prefix.rstrip("/") return path def _trusted_proxy(peer: str, trusted: set[str]) -> bool: if not trusted: return False return any(_trusted_proxy_candidate(peer, candidate) for candidate in trusted) def _trusted_proxy_candidate(peer: str, candidate: str) -> bool: try: peer_ip = ip_address(peer) except ValueError: return False try: if "/" in candidate: return peer_ip in ip_network(candidate, strict=False) return peer_ip == ip_address(candidate) except ValueError: return False def _should_trust_proxy_headers(peer: str, settings: DdnsSettings | None) -> bool: if settings and _trusted_proxy(peer, settings.trusted_proxy_ips): return True if settings and settings.trust_private_proxy_headers: return _private_proxy_peer(peer) return False def _private_proxy_peer(peer: str) -> bool: try: peer_ip = ip_address(peer) except ValueError: return False return any(peer_ip in network for network in PRIVATE_PROXY_NETWORKS) def _client_ip_from_headers(request: Request) -> str | None: for header_name in ("cf-connecting-ip", "true-client-ip", "x-real-ip"): candidate = _valid_header_ip(request.headers.get(header_name, "")) if candidate: return candidate for candidate in request.headers.get("x-forwarded-for", "").split(","): parsed = _valid_header_ip(candidate) if parsed: return parsed return _client_ip_from_forwarded(request.headers.get("forwarded", "")) def _client_ip_from_forwarded(value: str) -> str | None: for forwarded_pair in value.split(";"): key, separator, candidate = forwarded_pair.strip().partition("=") if separator and key.lower() == "for": parsed = _valid_header_ip(candidate) if parsed: return parsed return None def _valid_header_ip(value: str) -> str | None: cleaned = _strip_header_ip(value) if not cleaned: return None try: return str(ip_address(cleaned)) except ValueError: return None def _strip_header_ip(value: str) -> str: cleaned = _strip_header_ip_quotes(value) if _empty_header_ip(cleaned): return "" return _strip_bracketed_ip(cleaned) or _strip_host_port(cleaned) or cleaned def _strip_header_ip_quotes(value: str) -> str: return value.strip().strip('"').strip("'") def _empty_header_ip(value: str) -> bool: return not value or value.lower() == "unknown" def _strip_bracketed_ip(value: str) -> str: if value.startswith("[") and "]" in value: return value[1 : value.index("]")] return "" def _strip_host_port(value: str) -> str: if value.count(":") != 1: return "" host, port = value.rsplit(":", 1) return host if port.isdigit() else ""