Build KarlDNS self-hosted DynDNS service

This commit is contained in:
StasonJatham committed 2026-05-20 23:55:27 +02:00
1 parent 45bc38d950
commit ca92dc211a
65 files changed
+9200 -3570

No files matched your search

+66 -3
View File
@@ -4,6 +4,7 @@ import base64
import hashlib
import hmac
import secrets
import time
from datetime import UTC, datetime
from ipaddress import ip_address, ip_network
@@ -11,6 +12,10 @@ from fastapi import HTTPException, Request
from .ddns_models 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
def credentials_from_basic_auth(header: str) -> tuple[str | None, str | None]:
try:
@@ -44,10 +49,68 @@ def hash_lookup_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def admin_csrf_token(settings: DdnsSettings) -> str:
secret = settings.admin_password or settings.shared_secret
def sign_dashboard_unlock_cookie(
hostname: str,
management_slug: str,
password_hash: 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(hostname, management_slug, password_hash, timestamp, bounded_max_age)
return f"v2:{timestamp}:{bounded_max_age}:{signature}"
def verify_dashboard_unlock_cookie(hostname: str, management_slug: str, password_hash: str | None, cookie_value: str | None) -> bool:
if not password_hash or not cookie_value:
return False
try:
version, raw_timestamp, raw_max_age, supplied = cookie_value.split(":", 3)
timestamp = int(raw_timestamp)
max_age_seconds = _bounded_dashboard_unlock_seconds(int(raw_max_age))
except ValueError:
return False
if version != "v2":
return False
now = int(time.time())
if timestamp > now + 60 or now - timestamp > max_age_seconds:
return False
expected = _dashboard_unlock_signature(hostname, management_slug, password_hash, timestamp, max_age_seconds)
return hmac.compare_digest(supplied, expected)
def _dashboard_unlock_signature(
hostname: str,
management_slug: str,
password_hash: str,
timestamp: int,
max_age_seconds: int,
) -> str:
payload = f"{hostname}\0{management_slug}\0{timestamp}\0{max_age_seconds}".encode()
return hmac.new(password_hash.encode(), payload, hashlib.sha256).hexdigest()
def dashboard_csrf_token(management_slug: str, account: dict[str, str | int | None]) -> str:
hostname = str(account["hostname"])
password_hash = str(account.get("dashboard_password_hash") or "")
day = datetime.now(UTC).strftime("%Y-%m-%d")
return hmac.new(secret.encode("utf-8"), f"admin:{day}".encode(), hashlib.sha256).hexdigest()
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: dict[str, str | int | None], 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: