mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: cloakbrowser v0.1.0 — stealth Chromium wrapper for Playwright
Drop-in Playwright replacement with source-level fingerprint patches. Auto-downloads patched Chromium 142 from GitHub Releases on first use. 17/17 stealth tests passing (reCAPTCHA 0.9, Cloudflare Turnstile, BrowserScan).
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
"""Binary download and cache management for cloakbrowser.
|
||||
|
||||
Downloads the patched Chromium binary on first use, caches it locally.
|
||||
Similar to how Playwright downloads its own bundled Chromium.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
CHROMIUM_VERSION,
|
||||
get_binary_dir,
|
||||
get_binary_path,
|
||||
get_download_url,
|
||||
get_local_binary_override,
|
||||
get_platform_tag,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cloakbrowser")
|
||||
|
||||
# Timeout for download (large binary, allow 10 min)
|
||||
DOWNLOAD_TIMEOUT = 600.0
|
||||
|
||||
|
||||
def ensure_binary() -> str:
|
||||
"""Ensure the stealth Chromium binary is available. Download if needed.
|
||||
|
||||
Returns the path to the chrome executable as a string.
|
||||
|
||||
Set CLOAKBROWSER_BINARY_PATH to skip download and use a local build.
|
||||
"""
|
||||
# Check for local override first
|
||||
local_override = get_local_binary_override()
|
||||
if local_override:
|
||||
path = Path(local_override)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"CLOAKBROWSER_BINARY_PATH set to '{local_override}' but file does not exist"
|
||||
)
|
||||
logger.info("Using local binary override: %s", local_override)
|
||||
return str(path)
|
||||
|
||||
# Check if binary is already cached
|
||||
binary_path = get_binary_path()
|
||||
if binary_path.exists() and _is_executable(binary_path):
|
||||
logger.debug("Binary found in cache: %s", binary_path)
|
||||
return str(binary_path)
|
||||
|
||||
# Download
|
||||
logger.info(
|
||||
"Stealth Chromium %s not found. Downloading for %s...",
|
||||
CHROMIUM_VERSION,
|
||||
get_platform_tag(),
|
||||
)
|
||||
_download_and_extract()
|
||||
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Download completed but binary not found at expected path: {binary_path}. "
|
||||
f"This may indicate a packaging issue. Please report at "
|
||||
f"https://github.com/CloakHQ/cloakbrowser/issues"
|
||||
)
|
||||
|
||||
return str(binary_path)
|
||||
|
||||
|
||||
def _download_and_extract() -> None:
|
||||
"""Download the binary archive and extract to cache directory."""
|
||||
url = get_download_url()
|
||||
binary_dir = get_binary_dir()
|
||||
|
||||
# Create cache dir
|
||||
binary_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download to temp file first (atomic — no partial downloads in cache)
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
_download_file(url, tmp_path)
|
||||
_extract_archive(tmp_path, binary_dir)
|
||||
finally:
|
||||
# Clean up temp file
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _download_file(url: str, dest: Path) -> 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:
|
||||
response.raise_for_status()
|
||||
|
||||
total = int(response.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
last_logged_pct = -1
|
||||
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in response.iter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if total > 0:
|
||||
pct = int(downloaded / total * 100)
|
||||
# Log every 10%
|
||||
if pct >= last_logged_pct + 10:
|
||||
last_logged_pct = pct
|
||||
logger.info(
|
||||
"Download progress: %d%% (%d/%d MB)",
|
||||
pct,
|
||||
downloaded // (1024 * 1024),
|
||||
total // (1024 * 1024),
|
||||
)
|
||||
|
||||
logger.info("Download complete: %d MB", dest.stat().st_size // (1024 * 1024))
|
||||
|
||||
|
||||
def _extract_archive(archive_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract tar.gz archive to destination directory."""
|
||||
logger.info("Extracting to %s", dest_dir)
|
||||
|
||||
# Clean existing dir if partial download existed
|
||||
if dest_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(dest_dir)
|
||||
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
# Security: prevent path traversal and symlink attacks
|
||||
safe_members = []
|
||||
for member in tar.getmembers():
|
||||
if member.issym() or member.islnk():
|
||||
logger.warning("Skipping symlink in archive: %s", member.name)
|
||||
continue
|
||||
member_path = (dest_dir / member.name).resolve()
|
||||
if not str(member_path).startswith(str(dest_dir.resolve())):
|
||||
raise RuntimeError(f"Archive contains path traversal: {member.name}")
|
||||
safe_members.append(member)
|
||||
|
||||
tar.extractall(dest_dir, members=safe_members)
|
||||
|
||||
# If tar extracted into a single subdirectory, flatten it
|
||||
# (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome)
|
||||
_flatten_single_subdir(dest_dir)
|
||||
|
||||
# Make binary executable
|
||||
binary_path = get_binary_path()
|
||||
if binary_path.exists():
|
||||
_make_executable(binary_path)
|
||||
logger.info("Binary ready: %s", binary_path)
|
||||
|
||||
|
||||
def _flatten_single_subdir(dest_dir: Path) -> None:
|
||||
"""If extraction created a single subdirectory, move its contents up.
|
||||
|
||||
Many tar archives wrap files in a top-level directory (e.g.
|
||||
fingerprint-chromium-142-custom-v2/chrome). We want chrome at dest_dir/chrome.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
entries = list(dest_dir.iterdir())
|
||||
if len(entries) == 1 and entries[0].is_dir():
|
||||
subdir = entries[0]
|
||||
logger.debug("Flattening single subdirectory: %s", subdir.name)
|
||||
for item in subdir.iterdir():
|
||||
shutil.move(str(item), str(dest_dir / item.name))
|
||||
subdir.rmdir()
|
||||
|
||||
|
||||
def _is_executable(path: Path) -> bool:
|
||||
"""Check if a file is executable."""
|
||||
return os.access(path, os.X_OK)
|
||||
|
||||
|
||||
def _make_executable(path: Path) -> None:
|
||||
"""Make a file executable (chmod +x)."""
|
||||
current = path.stat().st_mode
|
||||
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Remove all cached binaries. Forces re-download on next launch."""
|
||||
from .config import get_cache_dir
|
||||
import shutil
|
||||
|
||||
cache_dir = get_cache_dir()
|
||||
if cache_dir.exists():
|
||||
shutil.rmtree(cache_dir)
|
||||
logger.info("Cache cleared: %s", cache_dir)
|
||||
|
||||
|
||||
def binary_info() -> dict:
|
||||
"""Return info about the current binary installation."""
|
||||
binary_path = get_binary_path()
|
||||
return {
|
||||
"version": CHROMIUM_VERSION,
|
||||
"platform": get_platform_tag(),
|
||||
"binary_path": str(binary_path),
|
||||
"installed": binary_path.exists(),
|
||||
"cache_dir": str(get_binary_dir()),
|
||||
"download_url": get_download_url(),
|
||||
}
|
||||
Reference in New Issue
Block a user