2026-05-20 13:48:24 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import secrets
|
2026-05-20 23:55:27 +02:00
|
|
|
import time
|
2026-05-22 17:05:06 +02:00
|
|
|
from collections.abc import Mapping
|
2026-06-17 23:17:31 +02:00
|
|
|
from dataclasses import dataclass
|
2026-05-20 13:48:24 +02:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from ipaddress import ip_address, ip_network
|
|
|
|
|
|
2026-05-20 17:57:42 +02:00
|
|
|
from fastapi import HTTPException, Request
|
2026-05-20 13:48:24 +02:00
|
|
|
|
2026-05-21 17:23:08 +02:00
|
|
|
from .settings import DdnsSettings
|
2026-05-20 13:48:24 +02:00
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
|
|
|
|
|
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
|
|
|
|
|
DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60
|
2026-05-21 18:30:15 +02:00
|
|
|
BEARER_SECRET_PREFIX = "karl_"
|
2026-05-21 18:15:40 +02:00
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-05-20 23:55:27 +02:00
|
|
|
|
2026-05-20 13:48:24 +02:00
|
|
|
|
2026-06-17 23:17:31 +02:00
|
|
|
@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()
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 13:48:24 +02:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 18:30:15 +02:00
|
|
|
def new_bearer_secret() -> str:
|
|
|
|
|
return f"{BEARER_SECRET_PREFIX}{secrets.token_urlsafe(32)}"
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 23:55:27 +02:00
|
|
|
def sign_dashboard_unlock_cookie(
|
2026-06-17 23:17:31 +02:00
|
|
|
identity: DashboardUnlockIdentity,
|
2026-05-21 10:05:40 +02:00
|
|
|
signing_secret: str,
|
2026-05-20 23:55:27 +02:00
|
|
|
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)
|
2026-05-21 10:05:40 +02:00
|
|
|
signature = _dashboard_unlock_signature(
|
2026-06-17 23:17:31 +02:00
|
|
|
identity,
|
2026-05-21 10:05:40 +02:00
|
|
|
signing_secret,
|
|
|
|
|
timestamp,
|
|
|
|
|
bounded_max_age,
|
|
|
|
|
)
|
|
|
|
|
return f"v3:{timestamp}:{bounded_max_age}:{signature}"
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
|
2026-05-21 10:05:40 +02:00
|
|
|
def verify_dashboard_unlock_cookie(
|
|
|
|
|
hostname: str,
|
|
|
|
|
management_slug: str,
|
|
|
|
|
password_hash: str | None,
|
|
|
|
|
signing_secret: str,
|
|
|
|
|
cookie_value: str | None,
|
|
|
|
|
) -> bool:
|
2026-06-17 23:17:31 +02:00
|
|
|
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:
|
2026-05-20 23:55:27 +02:00
|
|
|
return False
|
2026-06-17 23:17:31 +02:00
|
|
|
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:
|
2026-05-20 23:55:27 +02:00
|
|
|
try:
|
2026-06-17 23:17:31 +02:00
|
|
|
version, raw_timestamp, raw_max_age, signature = cookie_value.split(":", 3)
|
2026-05-20 23:55:27 +02:00
|
|
|
timestamp = int(raw_timestamp)
|
|
|
|
|
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
|
|
|
|
|
except ValueError:
|
2026-06-17 23:17:31 +02:00
|
|
|
return None
|
2026-05-21 10:05:40 +02:00
|
|
|
if version != "v3":
|
2026-06-17 23:17:31 +02:00
|
|
|
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
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _dashboard_unlock_signature(
|
2026-06-17 23:17:31 +02:00
|
|
|
identity: DashboardUnlockIdentity,
|
2026-05-21 10:05:40 +02:00
|
|
|
signing_secret: str,
|
2026-05-20 23:55:27 +02:00
|
|
|
timestamp: int,
|
|
|
|
|
max_age_seconds: int,
|
|
|
|
|
) -> str:
|
2026-06-17 23:17:31 +02:00
|
|
|
return hmac.new(signing_secret.encode(), identity.payload(timestamp, max_age_seconds), hashlib.sha256).hexdigest()
|
2026-05-21 10:05:40 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def dashboard_cookie_signing_secret(settings: DdnsSettings) -> str:
|
|
|
|
|
return settings.dashboard_cookie_secret or settings.admin_password
|
2026-05-20 23:55:27 +02:00
|
|
|
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
def dashboard_csrf_token(management_slug: str, account: Mapping[str, object]) -> str:
|
2026-05-20 23:55:27 +02:00
|
|
|
hostname = str(account["hostname"])
|
|
|
|
|
password_hash = str(account.get("dashboard_password_hash") or "")
|
2026-05-20 13:48:24 +02:00
|
|
|
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
2026-05-20 23:55:27 +02:00
|
|
|
key = f"{management_slug}\0{password_hash}".encode()
|
|
|
|
|
return hmac.new(key, f"dashboard:{hostname}:{day}".encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 17:05:06 +02:00
|
|
|
def require_dashboard_csrf(management_slug: str, account: Mapping[str, object], supplied: str) -> None:
|
2026-05-20 23:55:27 +02:00
|
|
|
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()
|
2026-05-20 13:48:24 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-05-21 18:15:40 +02:00
|
|
|
if _should_trust_proxy_headers(peer, settings):
|
|
|
|
|
forwarded_ip = _client_ip_from_headers(request)
|
|
|
|
|
if forwarded_ip:
|
|
|
|
|
return forwarded_ip
|
2026-05-20 13:48:24 +02:00
|
|
|
return peer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_rate_limited_path(path: str) -> bool:
|
2026-05-21 17:23:08 +02:00
|
|
|
return path == "/" or path.startswith(("/m/", "/api/v1/hostnames", "/api/v1/router/"))
|
2026-05-20 13:48:24 +02:00
|
|
|
|
|
|
|
|
|
2026-05-20 18:33:48 +02:00
|
|
|
def rate_limit_bucket(path: str) -> str:
|
2026-05-21 17:23:08 +02:00
|
|
|
for prefix in ("/m/", "/api/v1/hostnames", "/api/v1/router/"):
|
2026-05-20 18:33:48 +02:00
|
|
|
if path.startswith(prefix):
|
|
|
|
|
return prefix.rstrip("/")
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 13:48:24 +02:00
|
|
|
def _trusted_proxy(peer: str, trusted: set[str]) -> bool:
|
|
|
|
|
if not trusted:
|
|
|
|
|
return False
|
2026-06-17 23:17:31 +02:00
|
|
|
return any(_trusted_proxy_candidate(peer, candidate) for candidate in trusted)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _trusted_proxy_candidate(peer: str, candidate: str) -> bool:
|
2026-05-20 13:48:24 +02:00
|
|
|
try:
|
|
|
|
|
peer_ip = ip_address(peer)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return False
|
2026-06-17 23:17:31 +02:00
|
|
|
try:
|
|
|
|
|
if "/" in candidate:
|
|
|
|
|
return peer_ip in ip_network(candidate, strict=False)
|
|
|
|
|
return peer_ip == ip_address(candidate)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return False
|
2026-05-21 18:15:40 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-06-17 23:17:31 +02:00
|
|
|
cleaned = _strip_header_ip_quotes(value)
|
|
|
|
|
if _empty_header_ip(cleaned):
|
2026-05-21 18:15:40 +02:00
|
|
|
return ""
|
2026-06-17 23:17:31 +02:00
|
|
|
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 ""
|