cloakhq--cloakbrowser
446 行
15 KiB
Python
446 行
15 KiB
Python
"""CLI for cloakbrowser — download and manage the stealth Chromium binary.
|
|
|
|
Usage:
|
|
python -m cloakbrowser install # Download binary (with progress)
|
|
python -m cloakbrowser info # Environment + binary diagnostics
|
|
python -m cloakbrowser doctor # Alias for `info`
|
|
python -m cloakbrowser update # Check for and download newer binary
|
|
python -m cloakbrowser clear-cache # Remove cached binaries
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import logging
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import sys
|
|
|
|
UPGRADE_HINT = "→ Try the latest Pro binary (Chromium 148) free for 7 days: https://cloakbrowser.dev"
|
|
|
|
|
|
def _setup_logging() -> None:
|
|
"""Route cloakbrowser logger to stderr with clean output."""
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(message)s",
|
|
stream=sys.stderr,
|
|
force=True,
|
|
)
|
|
# Suppress noisy HTTP request logs from httpx
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
|
|
|
|
def cmd_install(args: argparse.Namespace) -> None:
|
|
from .download import ensure_binary
|
|
|
|
path = ensure_binary()
|
|
print(path)
|
|
|
|
|
|
def _module_available(module: str) -> bool:
|
|
try:
|
|
return importlib.util.find_spec(module) is not None
|
|
except (ImportError, ValueError):
|
|
return False
|
|
|
|
|
|
def _binary_version(binary_path: str) -> tuple[bool, str, str]:
|
|
"""Launch `<binary> --version` to prove it runs. Returns (ok, version, err)."""
|
|
try:
|
|
result = subprocess.run(
|
|
[binary_path, "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return False, "", str(exc)
|
|
if result.returncode != 0:
|
|
return False, "", (result.stderr or result.stdout).strip()
|
|
return True, result.stdout.strip(), ""
|
|
|
|
|
|
def _missing_shared_libs(binary_path: str) -> list[str]:
|
|
"""Linux-only: ldd the binary and return missing .so names (empty otherwise)."""
|
|
if platform.system() != "Linux":
|
|
return []
|
|
try:
|
|
result = subprocess.run(
|
|
["ldd", "--", binary_path], # -- so a path starting with - isn't read as a flag
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return []
|
|
missing = []
|
|
for line in result.stdout.splitlines():
|
|
if "=> not found" in line:
|
|
missing.append(line.split("=>")[0].strip())
|
|
return missing
|
|
|
|
|
|
def _resolve_license() -> tuple[dict, bool]:
|
|
"""Resolve + validate the license the way ensure_binary does.
|
|
|
|
Returns (license_section, entitled_to_pro). Network call (validate) only
|
|
happens when a key is actually present.
|
|
"""
|
|
from .license import resolve_license_key, validate_license
|
|
|
|
key = resolve_license_key(None)
|
|
# ensure_binary disables Pro routing when a custom download URL is set, so the
|
|
# diagnostic must report free too (matches download.py).
|
|
if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"):
|
|
key = None
|
|
if not key:
|
|
return {"tier": "free"}, False
|
|
try:
|
|
lic = validate_license(key)
|
|
except Exception as exc:
|
|
return {"tier": "unknown", "error": str(exc)}, False
|
|
if lic is None:
|
|
return {"tier": "unknown", "error": "could not validate"}, False
|
|
if lic.valid:
|
|
return {"tier": lic.plan, "valid": True, "expires": lic.expires}, True
|
|
return {"tier": "invalid", "valid": False}, False
|
|
|
|
|
|
def _effective_binary(entitled_pro: bool, quick: bool = False) -> dict:
|
|
"""Describe the binary ensure_binary would actually launch (no download).
|
|
|
|
Mirrors ensure_binary's resolution (override > version pin > license tier).
|
|
Unlike binary_info(), a Pro binary on disk is only reported when the license
|
|
entitles Pro — so a keyless run correctly shows the free binary even if a
|
|
Pro binary is cached.
|
|
"""
|
|
from .config import (
|
|
CHROMIUM_VERSION,
|
|
get_binary_dir,
|
|
get_binary_path,
|
|
get_effective_version,
|
|
get_local_binary_override,
|
|
normalize_requested_version,
|
|
)
|
|
|
|
override = get_local_binary_override()
|
|
if override:
|
|
return {
|
|
"version": None,
|
|
"latest_version": None,
|
|
"pinned": False,
|
|
"tier": "override",
|
|
"bundled_version": CHROMIUM_VERSION,
|
|
"path": override,
|
|
"installed": os.path.isfile(override),
|
|
"cache_dir": None,
|
|
"override": override,
|
|
}
|
|
|
|
requested = normalize_requested_version(None)
|
|
|
|
# For a Pro license, surface the server's latest separately from the version
|
|
# that will actually launch, so `info` can never silently diverge from launch
|
|
# (the divergence a customer hit: info showed latest, launch ran a stale cache).
|
|
# --quick keeps `info` fully network-free (skip the server latest lookup).
|
|
latest_version = None
|
|
if entitled_pro and not quick:
|
|
from .license import get_pro_latest_version
|
|
|
|
latest_version = get_pro_latest_version()
|
|
|
|
if requested:
|
|
version = requested
|
|
elif entitled_pro:
|
|
# "Will launch now" is the cached Pro build; if none is cached, the next
|
|
# launch downloads latest_version. get_effective_version(pro=True) returns
|
|
# None (never the free base) when nothing is cached.
|
|
version = get_effective_version(pro=True) or latest_version
|
|
else:
|
|
version = get_effective_version()
|
|
|
|
path = get_binary_path(version, pro=entitled_pro) if version else None
|
|
return {
|
|
"version": version,
|
|
"latest_version": latest_version,
|
|
"pinned": bool(requested),
|
|
"tier": "pro" if entitled_pro else "free",
|
|
"bundled_version": CHROMIUM_VERSION,
|
|
"path": str(path) if path else None,
|
|
"installed": bool(path) and path.exists(),
|
|
"cache_dir": str(get_binary_dir(version, pro=entitled_pro)) if version else None,
|
|
"override": None,
|
|
}
|
|
|
|
|
|
def _collect_diagnostics(quick: bool) -> dict:
|
|
"""Gather environment + binary diagnostics without triggering a download."""
|
|
diag: dict = {}
|
|
|
|
diag["environment"] = {
|
|
"python": sys.version.split()[0],
|
|
"os": platform.system(),
|
|
"arch": platform.machine(),
|
|
}
|
|
|
|
# Resolve the license up front — it decides which binary actually launches
|
|
# (ensure_binary only uses the Pro binary when a key validates). Computed
|
|
# before the binary section, displayed after it.
|
|
license_info, entitled_pro = _resolve_license()
|
|
|
|
from .config import get_platform_tag
|
|
|
|
try:
|
|
diag["environment"]["platform_tag"] = get_platform_tag()
|
|
except Exception as exc:
|
|
diag["environment"]["platform_tag"] = f"unavailable ({exc})"
|
|
|
|
try:
|
|
diag["binary"] = _effective_binary(entitled_pro, quick=quick)
|
|
except Exception as exc: # platform unsupported, etc.
|
|
diag["binary"] = {"error": str(exc)}
|
|
|
|
# Launch test — prove the binary actually executes (skipped by --quick).
|
|
binary = diag["binary"].get("path")
|
|
installed = diag["binary"].get("installed")
|
|
if quick:
|
|
diag["launch"] = {"tested": False, "reason": "skipped (--quick)"}
|
|
elif not binary or not (installed or (binary and os.path.isfile(binary))):
|
|
diag["launch"] = {"tested": False, "reason": "binary not installed"}
|
|
else:
|
|
ok, version, err = _binary_version(binary)
|
|
diag["launch"] = {"tested": True, "ok": ok, "version": version, "error": err}
|
|
if not ok:
|
|
diag["launch"]["missing_libs"] = _missing_shared_libs(binary)
|
|
|
|
# Windows-font probe — only meaningful on a Linux host spoofing Windows.
|
|
# Omitted entirely off Linux, where it carries no signal.
|
|
if platform.system() == "Linux":
|
|
from .browser import (
|
|
_OFFICE_FONT_TELLS,
|
|
_WINDOWS_FONT_TELLS,
|
|
_count_fonts_present,
|
|
)
|
|
|
|
# Strict count, not "any one present" — real font installs are atomic
|
|
# (you have the whole pack or none), so report how complete the set is.
|
|
win_n = _count_fonts_present(_WINDOWS_FONT_TELLS)
|
|
office_n = _count_fonts_present(_OFFICE_FONT_TELLS)
|
|
diag["fonts"] = {
|
|
"windows": None if win_n is None else [win_n, len(_WINDOWS_FONT_TELLS)],
|
|
"office": None if office_n is None else [office_n, len(_OFFICE_FONT_TELLS)],
|
|
}
|
|
|
|
diag["license"] = license_info
|
|
|
|
# GeoIP DB — presence only, never downloads.
|
|
from .geoip import GEOIP_DB_FILENAME, _get_geoip_dir
|
|
|
|
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
|
|
diag["geoip"] = {"db_present": db_path.exists(), "path": str(db_path)}
|
|
|
|
# Optional Python modules.
|
|
diag["modules"] = {
|
|
label: _module_available(module)
|
|
for label, module in {
|
|
"playwright": "playwright.sync_api",
|
|
"geoip2": "geoip2.database",
|
|
"aiohttp": "aiohttp",
|
|
"websockets": "websockets",
|
|
}.items()
|
|
}
|
|
|
|
return diag
|
|
|
|
|
|
def _print_diagnostics(diag: dict) -> None:
|
|
"""Render the diagnostics dict as a human-readable report."""
|
|
env = diag["environment"]
|
|
print("CloakBrowser diagnostics")
|
|
print(f"Python: {env['python']}")
|
|
print(f"OS: {env['os']} {env['arch']}")
|
|
print(f"Platform: {env.get('platform_tag', 'unknown')}")
|
|
|
|
binary = diag["binary"]
|
|
if "error" in binary:
|
|
print(f"Binary: unavailable ({binary['error']})")
|
|
else:
|
|
if binary["tier"] == "override":
|
|
print("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)")
|
|
else:
|
|
latest = binary.get("latest_version")
|
|
if latest:
|
|
# Pro: show what launches now AND the server's latest, so the two
|
|
# can never silently diverge.
|
|
print(f"Version: {binary['version']} ({binary['tier']}) — will launch")
|
|
if latest == binary["version"]:
|
|
print(f"Latest: {latest} (up to date)")
|
|
elif binary.get("pinned"):
|
|
print(
|
|
f"Latest: {latest} (available — pinned; unset "
|
|
"CLOAKBROWSER_VERSION to upgrade)"
|
|
)
|
|
else:
|
|
print(f"Latest: {latest} (available — next launch upgrades)")
|
|
elif binary["version"] is None:
|
|
# Pro with no cached build and no server answer (e.g. offline).
|
|
print(
|
|
f"Version: not downloaded yet ({binary['tier']}) "
|
|
"— next launch downloads the latest"
|
|
)
|
|
else:
|
|
print(f"Version: {binary['version']} ({binary['tier']})")
|
|
print(f"Binary: {binary['path']}")
|
|
print(f"Installed: {binary['installed']}")
|
|
if binary.get("cache_dir"):
|
|
print(f"Cache: {binary['cache_dir']}")
|
|
if binary.get("override"):
|
|
print(f"Override: {binary['override']} (CLOAKBROWSER_BINARY_PATH)")
|
|
|
|
launch = diag["launch"]
|
|
if not launch.get("tested"):
|
|
print(f"Launch: {launch['reason']}")
|
|
elif launch["ok"]:
|
|
print(f"Launch: ✓ {launch['version']}")
|
|
else:
|
|
print(f"Launch: ✗ failed — {launch['error']}")
|
|
for lib in launch.get("missing_libs", []):
|
|
print(f" missing: {lib}")
|
|
if launch.get("missing_libs"):
|
|
print(" → install the missing system libraries (e.g. apt-get install)")
|
|
|
|
if "fonts" in diag:
|
|
win = diag["fonts"]["windows"]
|
|
if win is None:
|
|
print("Win fonts: unknown (fc-list unavailable)")
|
|
else:
|
|
n, total = win
|
|
verdict = "ok" if n == total else "missing" if n == 0 else "partial"
|
|
print(f"Win fonts: {verdict} ({n}/{total})")
|
|
if n < total:
|
|
print(" → incomplete Windows font set; copy real Windows fonts (Segoe UI, Calibri, Consolas)")
|
|
office = diag["fonts"].get("office")
|
|
if office is not None:
|
|
n, total = office
|
|
# Office is informational only — no Office pack is a normal Windows
|
|
# persona (~53% of real machines have none), so no install nudge.
|
|
verdict = "ok" if n == total else "absent" if n == 0 else "partial"
|
|
print(f"Office fonts: {verdict} ({n}/{total})")
|
|
|
|
lic = diag["license"]
|
|
tier = lic["tier"]
|
|
if tier == "free":
|
|
print("License: Free")
|
|
print(f" {UPGRADE_HINT}")
|
|
elif "error" in lic:
|
|
print(f"License: {tier} ({lic['error']})")
|
|
else:
|
|
print(f"License: {tier}")
|
|
|
|
geoip = diag["geoip"]
|
|
print(f"GeoIP DB: {'present' if geoip['db_present'] else 'not downloaded (optional)'}")
|
|
|
|
print("Modules:")
|
|
for label, available in diag["modules"].items():
|
|
print(f" {label}: {'ok' if available else 'missing'}")
|
|
|
|
|
|
def cmd_info(args: argparse.Namespace) -> None:
|
|
quick = getattr(args, "quick", False)
|
|
diag = _collect_diagnostics(quick=quick)
|
|
if getattr(args, "json", False):
|
|
import json
|
|
|
|
print(json.dumps(diag, indent=2))
|
|
else:
|
|
_print_diagnostics(diag)
|
|
|
|
|
|
def cmd_update(args: argparse.Namespace) -> None:
|
|
from .download import check_for_pro_update, check_for_update
|
|
|
|
logger = logging.getLogger("cloakbrowser")
|
|
logger.info("Checking for updates...")
|
|
|
|
# A valid Pro license updates the Pro binary; everyone else updates free.
|
|
_, entitled_pro = _resolve_license()
|
|
if entitled_pro:
|
|
from .license import resolve_license_key
|
|
|
|
new_version = check_for_pro_update(resolve_license_key(None))
|
|
label = "Pro Chromium"
|
|
else:
|
|
new_version = check_for_update()
|
|
label = "Chromium"
|
|
|
|
if new_version:
|
|
print(f"Updated to {label} {new_version}")
|
|
else:
|
|
print("Already up to date.")
|
|
|
|
|
|
def cmd_clear_cache(args: argparse.Namespace) -> None:
|
|
from .config import get_cache_dir
|
|
from .download import clear_cache
|
|
|
|
if not get_cache_dir().exists():
|
|
print("No cache to clear.")
|
|
return
|
|
clear_cache()
|
|
print("Cache cleared.")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
prog="cloakbrowser",
|
|
description="Manage the CloakBrowser stealth Chromium binary.",
|
|
)
|
|
sub = parser.add_subparsers(dest="command")
|
|
|
|
sub.add_parser("install", help="Download the Chromium binary")
|
|
|
|
def _add_info_flags(p: argparse.ArgumentParser) -> None:
|
|
p.add_argument(
|
|
"--quick",
|
|
"--no-launch",
|
|
action="store_true",
|
|
dest="quick",
|
|
help="Skip the binary launch test (faster; the license is still validated)",
|
|
)
|
|
p.add_argument("--json", action="store_true", help="Emit diagnostics as JSON")
|
|
|
|
_add_info_flags(sub.add_parser("info", help="Environment + binary diagnostics"))
|
|
_add_info_flags(sub.add_parser("doctor", help="Alias for info"))
|
|
sub.add_parser("update", help="Check for and download a newer binary")
|
|
sub.add_parser("clear-cache", help="Remove all cached binaries")
|
|
|
|
args = parser.parse_args()
|
|
if not args.command:
|
|
parser.print_help()
|
|
sys.exit(2)
|
|
|
|
_setup_logging()
|
|
|
|
commands = {
|
|
"install": cmd_install,
|
|
"info": cmd_info,
|
|
"doctor": cmd_info,
|
|
"update": cmd_update,
|
|
"clear-cache": cmd_clear_cache,
|
|
}
|
|
|
|
try:
|
|
commands[args.command](args)
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|