mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: add Pro tier license validation and download routing
This commit is contained in:
@@ -14,6 +14,7 @@ Usage:
|
||||
from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
|
||||
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
||||
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
||||
from .license import LicenseInfo, validate_license
|
||||
from ._version import __version__
|
||||
|
||||
# Human-like behavioral layer (optional)
|
||||
@@ -44,6 +45,8 @@ __all__ = [
|
||||
"build_args",
|
||||
"maybe_resolve_geoip",
|
||||
"ProxySettings",
|
||||
"validate_license",
|
||||
"LicenseInfo",
|
||||
"HumanConfig",
|
||||
"resolve_human_config",
|
||||
"__version__",
|
||||
|
||||
+14
-6
@@ -147,6 +147,7 @@ def launch(
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth Chromium browser. Returns a Playwright Browser object.
|
||||
@@ -187,7 +188,7 @@ def launch(
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
binary_path = ensure_binary()
|
||||
binary_path = ensure_binary(license_key=license_key)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
@@ -248,6 +249,7 @@ async def launch_async( # noqa: C901
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch(). Returns a Playwright Browser object.
|
||||
@@ -286,7 +288,7 @@ async def launch_async( # noqa: C901
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
binary_path = ensure_binary()
|
||||
binary_path = ensure_binary(license_key=license_key)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
@@ -348,6 +350,7 @@ def launch_persistent_context(
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser with a persistent profile and return a BrowserContext.
|
||||
@@ -396,7 +399,7 @@ def launch_persistent_context(
|
||||
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
binary_path = ensure_binary(license_key=license_key)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
@@ -472,6 +475,7 @@ async def launch_persistent_context_async(
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch_persistent_context().
|
||||
@@ -522,7 +526,7 @@ async def launch_persistent_context_async(
|
||||
|
||||
timezone = _resolve_timezone(timezone, kwargs)
|
||||
|
||||
binary_path = ensure_binary()
|
||||
binary_path = ensure_binary(license_key=license_key)
|
||||
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||
proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy)
|
||||
args = _resolve_webrtc_args(args, proxy)
|
||||
@@ -597,6 +601,7 @@ def launch_context(
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||
@@ -641,7 +646,8 @@ def launch_context(
|
||||
# so it applies to ALL contexts, not just the default one.
|
||||
# locale and timezone are set via binary flags only — no CDP emulation.
|
||||
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||
timezone=timezone, locale=locale, extension_paths=extension_paths)
|
||||
timezone=timezone, locale=locale, extension_paths=extension_paths,
|
||||
license_key=license_key)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
@@ -694,6 +700,7 @@ async def launch_context_async(
|
||||
human_preset: HumanPreset = "default",
|
||||
human_config: HumanConfigOverrides | None = None,
|
||||
extension_paths: list[str] | None = None,
|
||||
license_key: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Async version of launch_context().
|
||||
@@ -757,7 +764,8 @@ async def launch_context_async(
|
||||
# so it applies to ALL contexts, not just the default one.
|
||||
# locale and timezone are set via binary flags only — no CDP emulation.
|
||||
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||
timezone=timezone, locale=locale, extension_paths=extension_paths)
|
||||
timezone=timezone, locale=locale, extension_paths=extension_paths,
|
||||
license_key=license_key)
|
||||
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if user_agent:
|
||||
|
||||
+22
-6
@@ -134,15 +134,16 @@ def get_cache_dir() -> Path:
|
||||
return Path.home() / ".cloakbrowser"
|
||||
|
||||
|
||||
def get_binary_dir(version: str | None = None) -> Path:
|
||||
def get_binary_dir(version: str | None = None, pro: bool = False) -> Path:
|
||||
"""Return the directory for a Chromium version binary."""
|
||||
v = version or get_chromium_version()
|
||||
return get_cache_dir() / f"chromium-{v}"
|
||||
suffix = "-pro" if pro else ""
|
||||
return get_cache_dir() / f"chromium-{v}{suffix}"
|
||||
|
||||
|
||||
def get_binary_path(version: str | None = None) -> Path:
|
||||
def get_binary_path(version: str | None = None, pro: bool = False) -> Path:
|
||||
"""Return the expected path to the chrome executable."""
|
||||
binary_dir = get_binary_dir(version)
|
||||
binary_dir = get_binary_dir(version, pro=pro)
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
# macOS: Chromium.app bundle
|
||||
@@ -172,15 +173,30 @@ def check_platform_available() -> None:
|
||||
)
|
||||
|
||||
|
||||
def get_effective_version() -> str:
|
||||
def get_effective_version(pro: bool = False) -> str:
|
||||
"""Return the best available version: auto-updated if available, else platform default.
|
||||
|
||||
Reads a platform-scoped marker file from the cache directory.
|
||||
Returns the platform's hardcoded version if no update has been downloaded.
|
||||
When pro=True, reads from the Pro-specific marker files.
|
||||
"""
|
||||
base = get_chromium_version()
|
||||
# Try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0
|
||||
cache = get_cache_dir()
|
||||
|
||||
if pro:
|
||||
marker = cache / f"latest_pro_version_{get_platform_tag()}"
|
||||
if marker.exists():
|
||||
try:
|
||||
version = marker.read_text().strip()
|
||||
if version:
|
||||
binary = get_binary_path(version, pro=True)
|
||||
if binary.exists():
|
||||
return version
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return base
|
||||
|
||||
# Free tier: try platform-scoped marker first, fall back to legacy marker
|
||||
for name in (f"latest_version_{get_platform_tag()}", "latest_version"):
|
||||
marker = cache / name
|
||||
if marker.exists():
|
||||
|
||||
+260
-8
@@ -45,6 +45,17 @@ from .config import (
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
|
||||
class BinaryVerificationError(RuntimeError):
|
||||
"""A downloaded binary could not be authenticated (bad/missing signature,
|
||||
version mismatch, or checksum failure).
|
||||
|
||||
Distinct from transient download/network errors: a verification failure is
|
||||
a tampering signal and MUST surface, never silently fall back to another
|
||||
binary. The Pro routing in ensure_binary re-raises this rather than
|
||||
downgrading to the free tier.
|
||||
"""
|
||||
|
||||
# Timeout for download (large binary, allow 10 min)
|
||||
DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
|
||||
|
||||
@@ -71,11 +82,14 @@ def _show_welcome() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_binary() -> str:
|
||||
def ensure_binary(license_key: str | None = None) -> str:
|
||||
"""Ensure the stealth Chromium binary is available. Download if needed.
|
||||
|
||||
Returns the path to the chrome executable as a string.
|
||||
|
||||
Args:
|
||||
license_key: Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var.
|
||||
|
||||
Set CLOAKBROWSER_BINARY_PATH to skip download and use a local build.
|
||||
"""
|
||||
# Check for local override first
|
||||
@@ -89,6 +103,39 @@ def ensure_binary() -> str:
|
||||
logger.info("Using local binary override: %s", local_override)
|
||||
return str(path)
|
||||
|
||||
# Pro license key check (custom download URL overrides Pro path)
|
||||
from .license import resolve_license_key, validate_license
|
||||
|
||||
key = resolve_license_key(license_key)
|
||||
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
||||
key = None
|
||||
|
||||
if key:
|
||||
info = validate_license(key)
|
||||
if info and info.valid:
|
||||
# A valid license is entitled to Pro, so Pro failures surface loudly
|
||||
# rather than silently substituting the older free binary. (A blip
|
||||
# during a routine update never reaches here: _ensure_pro_binary
|
||||
# returns the cached Pro binary and updates in the background.)
|
||||
try:
|
||||
return _ensure_pro_binary(key)
|
||||
except BinaryVerificationError:
|
||||
# Authenticity could not be confirmed — surface verbatim.
|
||||
raise
|
||||
except Exception as e:
|
||||
# Transient failure with no cached Pro binary to use — surface a
|
||||
# clear error rather than silently downloading the free binary.
|
||||
raise RuntimeError(
|
||||
f"Pro binary unavailable: {e}. Your license is valid but the "
|
||||
f"Pro binary could not be downloaded right now. Retry in a "
|
||||
f"moment. To use the free binary instead, unset "
|
||||
f"CLOAKBROWSER_LICENSE_KEY."
|
||||
) from e
|
||||
elif info:
|
||||
logger.warning("License validation failed (plan=%s), using free tier", info.plan)
|
||||
else:
|
||||
logger.warning("License validation unavailable, using free tier")
|
||||
|
||||
# Fail fast if no binary available for this platform
|
||||
check_platform_available()
|
||||
|
||||
@@ -176,6 +223,154 @@ def _download_and_extract(version: str | None = None) -> None:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _ensure_pro_binary(license_key: str) -> str:
|
||||
"""Ensure the Pro binary is downloaded and cached. Returns the binary path."""
|
||||
from .license import get_pro_latest_version
|
||||
|
||||
effective = get_effective_version(pro=True)
|
||||
binary_path = get_binary_path(effective, pro=True)
|
||||
|
||||
if binary_path.exists() and _is_executable(binary_path):
|
||||
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, effective)
|
||||
_show_welcome()
|
||||
_maybe_trigger_pro_update_check(license_key)
|
||||
return str(binary_path)
|
||||
|
||||
version = get_pro_latest_version()
|
||||
if not version:
|
||||
raise RuntimeError("Could not determine latest Pro version from server")
|
||||
|
||||
binary_path = get_binary_path(version, pro=True)
|
||||
if binary_path.exists() and _is_executable(binary_path):
|
||||
logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version)
|
||||
_show_welcome()
|
||||
return str(binary_path)
|
||||
|
||||
logger.info("Downloading Pro Chromium %s for %s...", version, get_platform_tag())
|
||||
_download_pro_binary(version, license_key)
|
||||
|
||||
binary_path = get_binary_path(version, pro=True)
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Pro download completed but binary not found at: {binary_path}"
|
||||
)
|
||||
|
||||
# Write Pro version marker (atomic)
|
||||
marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}"
|
||||
try:
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(version)
|
||||
os.replace(str(tmp), str(marker))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_show_welcome()
|
||||
return str(binary_path)
|
||||
|
||||
|
||||
def _download_pro_binary(version: str, license_key: str) -> None:
|
||||
"""Download a Pro binary from cloakbrowser.dev with license key auth.
|
||||
|
||||
Requests the explicit version so the served archive matches the signed
|
||||
manifest verified in _verify_pro_download.
|
||||
"""
|
||||
download_url = f"{DOWNLOAD_BASE_URL}/api/download/{version}"
|
||||
binary_dir = get_binary_dir(version, pro=True)
|
||||
binary_path = get_binary_path(version, pro=True)
|
||||
platform_tag = get_platform_tag()
|
||||
|
||||
binary_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
_download_file(
|
||||
download_url,
|
||||
tmp_path,
|
||||
headers={
|
||||
"Authorization": f"Bearer {license_key}",
|
||||
"X-Platform": platform_tag,
|
||||
},
|
||||
)
|
||||
|
||||
# Pro binaries come from cloakbrowser.dev — the same origin as free
|
||||
# downloads — so the M1 attack the Ed25519 signature defends against
|
||||
# applies equally. Verify with the same non-bypassable signature check;
|
||||
# CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the
|
||||
# official free path).
|
||||
_verify_pro_download(tmp_path, version)
|
||||
|
||||
_extract_archive(tmp_path, binary_dir, binary_path)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _verify_pro_download(file_path: Path, version: str) -> None:
|
||||
"""Verify a Pro archive with the same non-bypassable Ed25519 signature check
|
||||
as official free downloads.
|
||||
|
||||
Pro binaries are served from cloakbrowser.dev (same origin as the free
|
||||
tier), so a tampered same-origin SHA256SUMS could otherwise certify a
|
||||
tampered binary (M1, #308). Fetch the Pro SHA256SUMS + detached
|
||||
SHA256SUMS.sig, verify the signature against the pinned keys FIRST, bind the
|
||||
manifest to the requested version, then verify the archive's SHA-256.
|
||||
|
||||
An invalid signature, checksum, or version mismatch raises
|
||||
BinaryVerificationError (a tampering signal the router surfaces verbatim);
|
||||
CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is
|
||||
transient — nothing was validated — and raises a plain RuntimeError. A
|
||||
valid-license user is never silently downgraded to the free binary.
|
||||
"""
|
||||
base = f"{DOWNLOAD_BASE_URL}/releases/pro/chromium-v{version}"
|
||||
try:
|
||||
manifest_resp = httpx.get(
|
||||
f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0
|
||||
)
|
||||
manifest_resp.raise_for_status()
|
||||
sig_resp = httpx.get(
|
||||
f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0
|
||||
)
|
||||
sig_resp.raise_for_status()
|
||||
except Exception as exc:
|
||||
# Fetch failure is transient, not tampering — raise a plain RuntimeError
|
||||
# (the router reports it as "unavailable, retry") rather than a
|
||||
# BinaryVerificationError (which it surfaces as a tampering signal).
|
||||
raise RuntimeError(
|
||||
f"Could not fetch the signed SHA256SUMS for Pro {version} ({exc})"
|
||||
)
|
||||
|
||||
manifest_bytes = manifest_resp.content
|
||||
# _verify_signature / _verify_checksum raise plain RuntimeError; convert to
|
||||
# BinaryVerificationError so the Pro router treats them as tampering signals
|
||||
# (re-raise) rather than transient failures (fall back to free).
|
||||
try:
|
||||
_verify_signature(manifest_bytes, sig_resp.content)
|
||||
except RuntimeError as exc:
|
||||
raise BinaryVerificationError(str(exc)) from exc
|
||||
manifest_text = manifest_bytes.decode("utf-8")
|
||||
|
||||
# Version binding: same forced-downgrade defense as the official path.
|
||||
declared = _parse_manifest_version(manifest_text)
|
||||
if declared != version:
|
||||
raise BinaryVerificationError(
|
||||
f"Version mismatch in signed Pro SHA256SUMS: requested {version}, "
|
||||
f"manifest declares {declared or 'none'}. Refusing (possible downgrade)."
|
||||
)
|
||||
|
||||
tarball_name = get_archive_name()
|
||||
expected = _parse_checksums(manifest_text).get(tarball_name)
|
||||
if expected is None:
|
||||
raise BinaryVerificationError(
|
||||
f"Signature-verified Pro SHA256SUMS has no entry for {tarball_name} — "
|
||||
f"cannot confirm binary integrity."
|
||||
)
|
||||
try:
|
||||
_verify_checksum(file_path, expected)
|
||||
except RuntimeError as exc:
|
||||
raise BinaryVerificationError(str(exc)) from exc
|
||||
|
||||
|
||||
def _verify_download_checksum(file_path: Path, version: str | None = None) -> None:
|
||||
"""Verify the downloaded archive's integrity and authenticity.
|
||||
|
||||
@@ -388,11 +583,11 @@ def _verify_checksum(file_path: Path, expected_hash: str) -> None:
|
||||
logger.info("Checksum verified: SHA-256 OK")
|
||||
|
||||
|
||||
def _download_file(url: str, dest: Path) -> None:
|
||||
def _download_file(url: str, dest: Path, headers: dict[str, str] | None = None) -> None:
|
||||
"""Download a file with progress logging."""
|
||||
logger.info("Downloading from %s", url)
|
||||
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT) as response:
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=DOWNLOAD_TIMEOUT, headers=headers or {}) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
total = int(response.headers.get("content-length", 0))
|
||||
@@ -546,17 +741,34 @@ def clear_cache() -> None:
|
||||
|
||||
|
||||
def binary_info() -> dict:
|
||||
"""Return info about the current binary installation."""
|
||||
effective = get_effective_version()
|
||||
binary_path = get_binary_path(effective)
|
||||
"""Return info about the current binary installation.
|
||||
|
||||
tier reflects what is actually installed on disk, not merely whether a
|
||||
license is cached — a cached license with no Pro binary downloaded yet is
|
||||
still effectively running the free binary, and the active key may differ
|
||||
from the cached one.
|
||||
"""
|
||||
# Prefer Pro only if a Pro binary actually exists on disk.
|
||||
pro_version = get_effective_version(pro=True)
|
||||
pro_path = get_binary_path(pro_version, pro=True)
|
||||
pro = pro_path.exists() and _is_executable(pro_path)
|
||||
|
||||
if pro:
|
||||
effective = pro_version
|
||||
binary_path = pro_path
|
||||
else:
|
||||
effective = get_effective_version()
|
||||
binary_path = get_binary_path(effective)
|
||||
download_url = f"{DOWNLOAD_BASE_URL}/api/download/latest" if pro else get_download_url(effective)
|
||||
return {
|
||||
"version": effective,
|
||||
"tier": "pro" if pro else "free",
|
||||
"bundled_version": CHROMIUM_VERSION,
|
||||
"platform": get_platform_tag(),
|
||||
"binary_path": str(binary_path),
|
||||
"installed": binary_path.exists(),
|
||||
"cache_dir": str(get_binary_dir(effective)),
|
||||
"download_url": get_download_url(effective),
|
||||
"cache_dir": str(get_binary_dir(effective, pro=pro)),
|
||||
"download_url": download_url,
|
||||
}
|
||||
|
||||
|
||||
@@ -721,3 +933,43 @@ def _maybe_trigger_update_check() -> None:
|
||||
return
|
||||
t = threading.Thread(target=_check_and_download_update, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
def _maybe_trigger_pro_update_check(license_key: str) -> None:
|
||||
"""Fire-and-forget Pro binary update check in a daemon thread."""
|
||||
check_file = get_cache_dir() / ".last_pro_update_check"
|
||||
if check_file.exists():
|
||||
try:
|
||||
last_check = float(check_file.read_text().strip())
|
||||
if time.time() - last_check < UPDATE_CHECK_INTERVAL:
|
||||
return
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
def _check():
|
||||
try:
|
||||
from .license import get_pro_latest_version
|
||||
|
||||
check_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
check_file.write_text(str(time.time()))
|
||||
|
||||
latest = get_pro_latest_version()
|
||||
if not latest:
|
||||
return
|
||||
|
||||
if get_binary_path(latest, pro=True).exists():
|
||||
return
|
||||
|
||||
logger.info("Newer Pro binary available: %s. Downloading in background...", latest)
|
||||
_download_pro_binary(latest, license_key)
|
||||
|
||||
marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}"
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(latest)
|
||||
os.replace(str(tmp), str(marker))
|
||||
logger.info("Pro background update complete: %s ready. Will use on next launch.", latest)
|
||||
except Exception:
|
||||
logger.debug("Pro background update failed", exc_info=True)
|
||||
|
||||
t = threading.Thread(target=_check, daemon=True)
|
||||
t.start()
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""License validation and caching for CloakBrowser Pro.
|
||||
|
||||
Handles license key resolution, server validation with local caching,
|
||||
and Pro version checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_cache_dir
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate"
|
||||
PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version"
|
||||
|
||||
LICENSE_CACHE_TTL = 86400 # 24 hours
|
||||
PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour
|
||||
|
||||
|
||||
@dataclass
|
||||
class LicenseInfo:
|
||||
valid: bool
|
||||
plan: str
|
||||
expires: str | None
|
||||
|
||||
|
||||
def resolve_license_key(license_key: str | None = None) -> str | None:
|
||||
"""Resolve the license key: explicit param > env var > file > None."""
|
||||
if license_key and license_key.strip():
|
||||
return license_key.strip()
|
||||
env_key = os.environ.get("CLOAKBROWSER_LICENSE_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key
|
||||
key_file = get_cache_dir() / "license.key"
|
||||
try:
|
||||
content = key_file.read_text().strip()
|
||||
if content:
|
||||
return content
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def validate_license(license_key: str) -> LicenseInfo | None:
|
||||
"""Validate a license key with the CloakBrowser server.
|
||||
|
||||
Checks a local file cache first (24h TTL). Falls back to stale
|
||||
cache if the server is unreachable.
|
||||
|
||||
Returns LicenseInfo if validation succeeded, None on total failure.
|
||||
"""
|
||||
cache_path = get_cache_dir() / ".license_cache"
|
||||
key_sha = hashlib.sha256(license_key.encode()).hexdigest()
|
||||
|
||||
cached = _read_cache(cache_path, key_sha)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
VALIDATE_URL,
|
||||
json={"license_key": license_key},
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
info = LicenseInfo(
|
||||
valid=data.get("valid", False),
|
||||
plan=data.get("plan", "solo"),
|
||||
expires=data.get("expires"),
|
||||
)
|
||||
|
||||
if info.valid:
|
||||
_write_cache(cache_path, key_sha, info)
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("License validation request failed: %s", e)
|
||||
|
||||
stale = _read_cache(cache_path, key_sha, ignore_ttl=True)
|
||||
if stale:
|
||||
logger.warning("Using cached license validation (server unreachable)")
|
||||
return stale
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_pro_latest_version() -> str | None:
|
||||
"""Get the latest Pro binary version from the server.
|
||||
|
||||
Rate-limited to 1 call per hour via a marker file.
|
||||
"""
|
||||
marker = get_cache_dir() / ".last_pro_version_check"
|
||||
|
||||
if marker.exists():
|
||||
try:
|
||||
age = time.time() - marker.stat().st_mtime
|
||||
if age < PRO_VERSION_CHECK_INTERVAL:
|
||||
content = marker.read_text().strip()
|
||||
return content if content else None
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
resp = httpx.get(PRO_VERSION_URL, timeout=10.0)
|
||||
resp.raise_for_status()
|
||||
version = resp.json().get("version")
|
||||
if not version:
|
||||
return None
|
||||
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = marker.with_suffix(".tmp")
|
||||
tmp.write_text(version)
|
||||
os.replace(str(tmp), str(marker))
|
||||
return version
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Pro version check failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _read_cache(
|
||||
cache_path: Path, key_sha: str, ignore_ttl: bool = False
|
||||
) -> LicenseInfo | None:
|
||||
"""Read cached license validation if it exists and is fresh."""
|
||||
try:
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
|
||||
data = json.loads(cache_path.read_text())
|
||||
|
||||
if data.get("key_sha256") != key_sha:
|
||||
return None
|
||||
|
||||
if not ignore_ttl:
|
||||
validated_at = data.get("validated_at", 0)
|
||||
if time.time() - validated_at > LICENSE_CACHE_TTL:
|
||||
return None
|
||||
|
||||
expires = data.get("expires")
|
||||
if expires:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
exp_dt = datetime.fromisoformat(expires)
|
||||
if exp_dt.tzinfo is None:
|
||||
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
|
||||
if exp_dt < datetime.now(timezone.utc):
|
||||
return LicenseInfo(valid=False, plan=data.get("plan", "solo"), expires=expires)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return LicenseInfo(
|
||||
valid=data.get("valid", False),
|
||||
plan=data.get("plan", "solo"),
|
||||
expires=expires,
|
||||
)
|
||||
except (json.JSONDecodeError, OSError, KeyError, TypeError):
|
||||
# TypeError: a corrupted cache with a non-numeric validated_at. Treat any
|
||||
# unreadable cache as absent rather than crashing the caller.
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(cache_path: Path, key_sha: str, info: LicenseInfo) -> None:
|
||||
"""Write license validation result to local cache (atomic via tmp+rename)."""
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = cache_path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps({
|
||||
"key_sha256": key_sha,
|
||||
"valid": info.valid,
|
||||
"plan": info.plan,
|
||||
"expires": info.expires,
|
||||
"validated_at": time.time(),
|
||||
}))
|
||||
os.replace(str(tmp_path), str(cache_path))
|
||||
except OSError as e:
|
||||
logger.debug("Failed to write license cache: %s", e)
|
||||
Reference in New Issue
Block a user