From 214d2d2ff7f68cd49ad8b8aaac82a170371173b4 Mon Sep 17 00:00:00 2001 From: Dr Jones Date: Thu, 16 Apr 2026 00:27:39 -0700 Subject: [PATCH] Proxy auth UI, verbose Live logs, build script, docs - Fixed exit and manual chain: optional user/pass fields with URL merge and redaction in UI/logs - config: split_proxy_for_edit, merge_proxy_credentials, redact_proxy_url, IPv6-style host bracketing - Live tab: UILogHandler to stream proxy_chain_manager logs; Verbose toggle; Clear log; httpx quiet - service/validator/fetcher: structured INFO/DEBUG for pool, GOST, validation, fetches - setup_and_build.ps1: pip upgrade, deps, PyInstaller, desktop copy + shortcut; build_exe.bat delegates - README: clone/pull to one-script desktop build flow Made-with: Cursor --- README.md | 74 ++++++++-------- build_exe.bat | 20 +---- proxy_chain_manager/app.py | 142 +++++++++++++++++++++++++++++-- proxy_chain_manager/config.py | 76 +++++++++++++++++ proxy_chain_manager/fetcher.py | 1 + proxy_chain_manager/service.py | 91 ++++++++++++++++++-- proxy_chain_manager/validator.py | 88 ++++++++++++++----- scripts/setup_and_build.ps1 | 63 ++++++++++++++ tests/test_proxy_god.py | 29 ++++++- 9 files changed, 495 insertions(+), 89 deletions(-) create mode 100644 scripts/setup_and_build.ps1 diff --git a/README.md b/README.md index 5809111..4a510cf 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,51 @@ YOU → VPN (outer tunnel) → Hop 1 → Hop 2 → … → Last hop → Internet ## Requirements - **Windows 10/11 x64** -- **Python 3.10+** *or* the built **`.exe`** +- **Python 3.10+** on PATH (or the **`py`** launcher with a 3.10+ install) — *only needed to build*; the shipped **`.exe`** runs without Python - **VPN** recommended (Nord or any)—outer tunnel before the proxy zoo --- -## Quick start (source) +## Clone / pull → one build script → Desktop + +Typical flow: + +1. **Clone** (or `git pull` if you already have the repo): + ```cmd + git clone https://gitea.thetempleofdoom.com/drjones/proxy-god.git + cd proxy-god + ``` +2. **Python** (first machine only): if `python --version` / `py -3` isn’t 3.10+, install then reopen the terminal: + ```cmd + winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements + ``` +3. **Build** — upgrades **pip**, installs **requirements.txt** + **PyInstaller**, produces **`dist\ProxyChainManager.exe`**, copies **`ProxyChainManager.exe`** to your **Desktop**, and creates **`Proxy God.lnk`** pointing at it: + ```powershell + powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\setup_and_build.ps1 + ``` + Same steps from **`build_exe.bat`** in the repo root (it calls that script). + +| Where | What | +|--------|------| +| `dist\ProxyChainManager.exe` | Built app (same bits as Desktop copy) | +| **Desktop → `Proxy God.lnk`** | **Double‑click this** — shortcut to the Desktop exe | +| **Desktop → `ProxyChainManager.exe`** | Same app (refreshed every build) | + +After a successful build, if the shortcut is missing but the exe exists, you can recreate it: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\create_desktop_shortcut.ps1 +``` + +First run of the **exe**: accept **UAC** if you want the firewall kill-switch. In the app, press **▶ Start**. + +You do **not** need Python installed to **run** the Desktop **`.exe`** — only to **build** it with the script above. + +--- + +## Quick start (run from source, no exe) ```cmd -git clone https://gitea.thetempleofdoom.com/drjones/proxy-god.git cd proxy-god pip install -r requirements.txt python run.py @@ -64,41 +100,11 @@ python -m unittest discover -s tests -v Hits config sanitization, fetcher smoke, validator timeouts, and a live `get_direct_ip` check (skips if you’re offline). -### Shortcut missing? - -After a successful build, if **`Proxy God.lnk`** isn’t on the Desktop, run: - -```cmd -powershell -ExecutionPolicy Bypass -File scripts\create_desktop_shortcut.ps1 -``` - -(from the repo folder) - ---- - -## Build the binary - -```cmd -build_exe.bat -``` - -**What you get (no extra steps):** - -| Where | What | -|--------|------| -| `dist\ProxyChainManager.exe` | The built app | -| **Desktop → `Proxy God.lnk`** | **Double‑click this** — shortcut to the exe (created every build) | -| **Desktop → `ProxyChainManager.exe`** | Same app, direct file | - -First time: accept **UAC** if you want the firewall kill-switch. Then in the window press **▶ Start**. - -You do **not** need Python on the machine to run the `.exe` — only to **build** it. - --- ## Run it like you mean it -1. Launch **`ProxyChainManager.exe`** (UAC if you want the kill-switch). +1. Launch **`Proxy God.lnk`** on the Desktop (or **`ProxyChainManager.exe`**) — UAC if you want the kill-switch. 2. Tweak **Settings** if you’re picky—defaults are **solid** out of the box. 3. Hit **Start**. First run grabs **GOST**, whitelists the folder in Defender, pulls lists, validates, chains, verifies exit IP, sets **system proxy**, engages **firewall** (if Admin). 4. Close the window → **tray**; the engine **keeps running** until you **Quit**. diff --git a/build_exe.bat b/build_exe.bat index 8c93700..4d4eaa9 100644 --- a/build_exe.bat +++ b/build_exe.bat @@ -1,22 +1,6 @@ @echo off setlocal cd /d "%~dp0" - -python -m pip install -r requirements.txt -python -m pip install pyinstaller - -python -m PyInstaller --noconfirm --clean ^ - --onefile --windowed ^ - --uac-admin ^ - --name ProxyChainManager ^ - --collect-all customtkinter ^ - --hidden-import pystray._win32 ^ - run.py - -copy /Y "dist\ProxyChainManager.exe" "%USERPROFILE%\Desktop\ProxyChainManager.exe" >nul -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\create_desktop_shortcut.ps1" -echo. -echo Built: dist\ProxyChainManager.exe -echo Desktop: ProxyChainManager.exe + shortcut "Proxy God.lnk" +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\setup_and_build.ps1" +if errorlevel 1 exit /b 1 endlocal - \ No newline at end of file diff --git a/proxy_chain_manager/app.py b/proxy_chain_manager/app.py index a6a24b7..deb4b6a 100644 --- a/proxy_chain_manager/app.py +++ b/proxy_chain_manager/app.py @@ -18,8 +18,11 @@ from .config import ( OBFUSCATION_MODES, Settings, load_settings, + merge_proxy_credentials, normalize_proxy_url, + redact_proxy_url, save_settings, + split_proxy_for_edit, ) from .firewall import ( disengage as fw_disengage, @@ -43,6 +46,21 @@ logging.basicConfig( ], ) + +class UILogHandler(logging.Handler): + """Forward ``proxy_chain_manager`` log records to the Live tab (via ``ui_q``). Thread-safe.""" + + def __init__(self, q: queue.Queue[dict[str, Any]]) -> None: + super().__init__(level=logging.DEBUG) + self._q = q + + def emit(self, record: logging.LogRecord) -> None: + try: + text = self.format(record) + self._q.put_nowait({"type": "log", "text": text}) + except Exception: + self.handleError(record) + # ── palette ────────────────────────────────────────────────────────────────── BG = "#0d0d1a" CARD = "#12122a" @@ -104,6 +122,7 @@ def main() -> None: pulse_job["id"] = None def _short(u: str) -> str: + u = redact_proxy_url(u) u = u.replace("http://", "").replace("socks5://", "s5://").replace("socks4://", "s4://").replace("https://", "") return u[:28] + "…" if len(u) > 30 else u @@ -405,16 +424,56 @@ def main() -> None: log_frame = ctk.CTkFrame(tab_live, fg_color=CARD, corner_radius=8) log_frame.pack(fill="both", expand=True) log_box = ctk.CTkTextbox( - log_frame, font=("Consolas", 11), + log_frame, font=("Consolas", 10), fg_color=BG, text_color=TEXT, scrollbar_button_color=ACCENT, ) - log_box.pack(fill="both", expand=True, padx=4, pady=4) + log_box.pack(fill="both", expand=True, padx=4, pady=(0, 4)) def _log(line: str) -> None: log_box.insert("end", f"[{_ts()}] {line}\n") log_box.see("end") + log_tool = ctk.CTkFrame(log_frame, fg_color="transparent") + log_tool.pack(fill="x", padx=8, pady=(6, 0), before=log_box) + verbose_logs_var = ctk.BooleanVar(value=True) + _pcm_logger = logging.getLogger("proxy_chain_manager") + _ui_log_handler: UILogHandler | None = None + for _h in _pcm_logger.handlers: + if isinstance(_h, UILogHandler): + _ui_log_handler = _h + break + if _ui_log_handler is None: + _ui_log_handler = UILogHandler(ui_q) + _ui_log_handler.setFormatter( + logging.Formatter("%(levelname)-5s [%(name)s] %(message)s") + ) + _pcm_logger.addHandler(_ui_log_handler) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + def _sync_ui_log_level() -> None: + on = bool(verbose_logs_var.get()) + lvl = logging.DEBUG if on else logging.INFO + _pcm_logger.setLevel(lvl) + if _ui_log_handler is not None: + _ui_log_handler.setLevel(lvl) + _log(f"UI log level: {'DEBUG (verbose)' if on else 'INFO'}") + + ctk.CTkCheckBox( + log_tool, + text="Verbose logs (DEBUG in UI — file stays INFO)", + variable=verbose_logs_var, + command=_sync_ui_log_level, + font=(FONT, 11), + fg_color=ACCENT2, + hover_color=ACCENT, + text_color=TEXT, + ).pack(side="left", padx=(0, 12)) + _btn(log_tool, "Clear log", lambda: log_box.delete("1.0", "end"), w=88, h=26, + fg_color=DIM, hover_color=ACCENT).pack(side="left", padx=(0, 6)) + _sync_ui_log_level() + # ─── TAB: CHAIN BUILDER ────────────────────────────────────────────────── cb_split = ctk.CTkFrame(tab_chain, fg_color="transparent") cb_split.pack(fill="x", pady=(4, 8)) @@ -492,11 +551,37 @@ def main() -> None: height=28, ) exit_proxy_entry.pack(fill="x", padx=10, pady=(0, 4)) - if s.manual_exit_proxy: - exit_proxy_entry.insert(0, s.manual_exit_proxy) + exit_auth_row = ctk.CTkFrame(exit_fix_frame, fg_color="transparent") + exit_auth_row.pack(fill="x", padx=10, pady=(0, 4)) + ctk.CTkLabel(exit_auth_row, text="User", font=(FONT, 10), text_color=TEXT2, width=36).pack( + side="left", padx=(0, 4) + ) + exit_user_entry = ctk.CTkEntry( + exit_auth_row, width=120, height=26, font=("Consolas", 10), + fg_color=BG, border_color=ACCENT, placeholder_text="optional", + ) + exit_user_entry.pack(side="left", padx=(0, 10)) + ctk.CTkLabel(exit_auth_row, text="Pass", font=(FONT, 10), text_color=TEXT2, width=36).pack( + side="left", padx=(0, 4) + ) + exit_pass_entry = ctk.CTkEntry( + exit_auth_row, width=120, height=26, font=("Consolas", 10), + fg_color=BG, border_color=ACCENT, placeholder_text="optional", show="*", + ) + exit_pass_entry.pack(side="left", padx=(0, 0)) + + _eb, _eu, _epw = split_proxy_for_edit(s.manual_exit_proxy) + if _eb: + exit_proxy_entry.insert(0, _eb) + if _eu: + exit_user_entry.insert(0, _eu) + if _epw: + exit_pass_entry.insert(0, _epw) + ctk.CTkLabel( exit_fix_frame, text="Earlier hops still rotate randomly. Empty = fully automatic exit.\n" + "Auth: use User/Pass (saved in settings) or paste user:pass@host in the URL field.\n" "Ignored while “Pin & use this chain” is enabled.", font=(FONT, 9), text_color=TEXT2, @@ -574,10 +659,21 @@ def main() -> None: add_row = ctk.CTkFrame(cb_right, fg_color="transparent") add_row.pack(fill="x", padx=12, pady=(0, 4)) add_entry = ctk.CTkEntry( - add_row, placeholder_text="http://ip:port or socks5://ip:port", + add_row, + placeholder_text="http://host:port or user:pass@host:port", font=(FONT, 10), fg_color=BG, border_color=ACCENT, height=28, ) add_entry.pack(side="left", fill="x", expand=True, padx=(0, 4)) + add_user_entry = ctk.CTkEntry( + add_row, width=88, height=28, font=("Consolas", 10), + fg_color=BG, border_color=ACCENT, placeholder_text="user", + ) + add_user_entry.pack(side="left", padx=(0, 2)) + add_pass_entry = ctk.CTkEntry( + add_row, width=88, height=28, font=("Consolas", 10), + fg_color=BG, border_color=ACCENT, placeholder_text="pass", show="*", + ) + add_pass_entry.pack(side="left", padx=(0, 4)) def _add_proxy() -> None: v = add_entry.get().strip() @@ -585,9 +681,17 @@ def main() -> None: return if "://" not in v: v = "http://" + v + au = add_user_entry.get().strip() + ap = add_pass_entry.get().strip() + if au or ap: + v = merge_proxy_credentials(v, au, ap) + else: + v = normalize_proxy_url(v) manual_chain.append(v) _rebuild_chain_ui() add_entry.delete(0, "end") + add_user_entry.delete(0, "end") + add_pass_entry.delete(0, "end") _btn(add_row, "+ Add", _add_proxy, w=68, h=28).pack(side="left") @@ -696,7 +800,11 @@ def main() -> None: obfuscation_mode=mode_var.get(), use_pinned_chain=bool(use_manual_var.get()), pinned_chain=list(manual_chain), - manual_exit_proxy=normalize_proxy_url(exit_proxy_entry.get()), + manual_exit_proxy=merge_proxy_credentials( + exit_proxy_entry.get(), + exit_user_entry.get(), + exit_pass_entry.get(), + ), health_check_seconds=min(3600, max(10, int(entries["health"].get().strip()))), full_refresh_seconds=min(86400, max(60, int(entries["refresh"].get().strip()))), validation_concurrency=max(1, int(entries["conc"].get().strip())), @@ -756,16 +864,33 @@ def main() -> None: elif t == "phase": ph = str(m.get("phase", "")) - phase_lbl.configure(text=ph) + phase_labels = { + "validate": "validate", + "fetch": "fetch", + "running": "running", + "exit_check": "fixed exit", + "verify_chain": "verify chain", + "gost_start": "starting chain", + } + phase_lbl.configure(text=phase_labels.get(ph, ph or "idle")) if ph == "validate": prog_bar.configure(progress_color=YELLOW) elif ph == "fetch": prog_bar.configure(progress_color=ORANGE) prog_bar.set(0.15) + elif ph == "exit_check": + prog_bar.configure(progress_color=YELLOW) + prog_bar.set(0.96) + elif ph == "gost_start": + prog_bar.configure(progress_color=GREEN) + prog_bar.set(0.985) + elif ph == "verify_chain": + prog_bar.configure(progress_color=GREEN) + prog_bar.set(0.98) elif ph == "running": prog_bar.configure(progress_color=GREEN) prog_bar.set(1.0) - else: + elif ph in ("idle", ""): prog_bar.set(0) elif t == "validate_progress": @@ -849,6 +974,7 @@ def main() -> None: _log("Press START to fetch, validate and chain proxies.") _log("Use +/− in top bar to change hop count instantly.") _log("Chain Builder → Fixed exit: set the last hop (optional); rest still rotates.") + _log("Verbose logs (Live tab): DEBUG lines from the engine + validator + fetcher (httpx kept quiet).") _log("─" * 60) _pump() root.mainloop() diff --git a/proxy_chain_manager/config.py b/proxy_chain_manager/config.py index 5ad45eb..1adcb19 100644 --- a/proxy_chain_manager/config.py +++ b/proxy_chain_manager/config.py @@ -3,9 +3,22 @@ from __future__ import annotations import json from dataclasses import asdict, dataclass, field from pathlib import Path +from urllib.parse import quote, unquote, urlparse, urlunparse from .paths import app_data_dir + +def _host_port_for_url(host: str, port: int | None) -> str: + """``host`` and optional ``port`` as used in proxy URLs (bracket IPv6 literals).""" + if not host: + return "" + if ":" in host and "." not in host: + inner = f"[{host}]" + else: + inner = host + return f"{inner}:{port}" if port else inner + + # Always bind the local forwarder to loopback. VM clones and NIC/IP changes (DHCP) # do not affect 127.0.0.1; avoid storing LAN IPs or hostnames for the listener. LISTEN_HOST = "127.0.0.1" @@ -21,6 +34,69 @@ def normalize_proxy_url(raw: str) -> str: return t.rstrip("/") +def split_proxy_for_edit(raw: str) -> tuple[str, str, str]: + """Split stored proxy URL into (url_without_credentials, username, password). + + Used to populate fixed-exit / form fields. Credentials are percent-decoded. + """ + t = normalize_proxy_url((raw or "").strip()) + if not t: + return "", "", "" + p = urlparse(t) + if not p.hostname: + return t, "", "" + host = p.hostname + port = p.port + scheme = p.scheme or "http" + base = f"{scheme}://{_host_port_for_url(host, port)}" + u = unquote(p.username) if p.username else "" + pw = unquote(p.password) if p.password else "" + return base, u, pw + + +def merge_proxy_credentials(raw_url: str, username: str = "", password: str = "") -> str: + """Build a single proxy URL: host/port from ``raw_url`` (any embedded auth ignored) plus optional auth. + + User and password are URL-encoded for ``@`` and ``:`` inside values. + """ + base = normalize_proxy_url((raw_url or "").strip()) + if not base: + return "" + p = urlparse(base) + if not p.hostname: + return base + host = p.hostname + port = p.port + scheme = p.scheme or "http" + user = (username or "").strip() + pw = (password or "").strip() + if not user and not pw: + # Leave credentials embedded in URL when form fields are blank (paste user:pass@host). + return base + hp = _host_port_for_url(host, port) + netloc = f"{quote(user, safe='')}:{quote(pw, safe='')}@{hp}" + return urlunparse((scheme, netloc, "", "", "", "")).rstrip("/") + + +def redact_proxy_url(url: str) -> str: + """Same URL shape for logs/UI, with credentials replaced by ``***``.""" + t = (url or "").strip() + if not t: + return "" + if "://" not in t: + t = "http://" + t + p = urlparse(t.rstrip("/")) + if not p.hostname: + return t + has_auth = bool(p.username) or bool(p.password) + if not has_auth: + return t.rstrip("/") + scheme = p.scheme or "http" + hp = _host_port_for_url(p.hostname, p.port) + netloc = f"***:***@{hp}" + return urlunparse((scheme, netloc, "", "", "", "")).rstrip("/") + + OBFUSCATION_MODES = ["auto", "http_only", "socks5_only", "random_mix"] OBFUSCATION_LABELS = { "auto": "Auto (best available)", diff --git a/proxy_chain_manager/fetcher.py b/proxy_chain_manager/fetcher.py index 26ebc02..5791819 100644 --- a/proxy_chain_manager/fetcher.py +++ b/proxy_chain_manager/fetcher.py @@ -9,6 +9,7 @@ log = logging.getLogger(__name__) def fetch_proxy_json(url: str, timeout: float = 45.0) -> list[dict[str, Any]]: + log.debug("fetch_proxy_json: GET %s", (url[:100] + "…") if len(url) > 100 else url) with httpx.Client(timeout=timeout, follow_redirects=True) as c: r = c.get(url) r.raise_for_status() diff --git a/proxy_chain_manager/service.py b/proxy_chain_manager/service.py index 43e8d0b..8545b64 100644 --- a/proxy_chain_manager/service.py +++ b/proxy_chain_manager/service.py @@ -9,7 +9,13 @@ from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from typing import Any -from .config import Settings, load_settings, normalize_proxy_url, save_settings +from .config import ( + Settings, + load_settings, + normalize_proxy_url, + redact_proxy_url, + save_settings, +) from .fetcher import fetch_proxy_json, normalize_entries from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, terminate_process @@ -21,7 +27,7 @@ log = logging.getLogger(__name__) Notify = Callable[[dict[str, Any]], None] # Thread pool for blocking I/O (proxy list fetching) so we don't stall asyncio -_FETCH_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="fetcher") +_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher") class ChainService: @@ -107,6 +113,14 @@ class ChainService: async def _async_main(self) -> None: self._notify({"type": "state", "running": True}) + log.info( + "Service start: chain_length=%d mode=%s sources=%d pinned=%s kill_switch=%s", + self._settings.chain_length, + self._settings.obfuscation_mode, + len(self._settings.sources), + self._settings.use_pinned_chain, + self._settings.kill_switch_enabled, + ) # ── GOST setup ─────────────────────────────────────────────────────── try: @@ -146,6 +160,15 @@ class ChainService: self._notify({"type": "log", "text": "Kill-switch disabled in settings."}) self._notify({"type": "firewall", "engaged": False}) + log.debug( + "Listener %s | refresh=%ds health=%ds max_candidates=%d concurrency=%d", + self._settings.listen_addr(), + int(self._settings.full_refresh_seconds), + int(self._settings.health_check_seconds), + int(self._settings.max_candidates), + int(self._settings.validation_concurrency), + ) + # ── Main rotation loop ──────────────────────────────────────────────── full_pool: list[str] = [] last_full = 0.0 @@ -157,12 +180,22 @@ class ChainService: not full_pool or now - last_full >= float(self._settings.full_refresh_seconds) ) + log.debug( + "Loop tick: full_pool=%d available=%d used=%d blacklist=%d need_refresh=%s age=%.0fs", + len(full_pool), + len(self._available), + len(self._used), + len(self._blacklist), + need_refresh, + (now - last_full) if full_pool else 0.0, + ) # Pinned (manual) chain mode — skip pool management if self._settings.use_pinned_chain and self._settings.pinned_chain: chain = list(self._settings.pinned_chain) rotation_num += 1 self._notify({"type": "rotation", "n": rotation_num}) + log.info("Pinned chain run: %d hops", len(chain)) ok = await self._run_chain(gost, chain, real_ip) if not ok: await self._sleep_interruptible(10) @@ -185,6 +218,7 @@ class ChainService: # ── Pool refresh ───────────────────────────────────────────────── if need_refresh: self._notify({"type": "phase", "phase": "fetch"}) + log.info("Pool refresh: fetching %d source(s)…", len(self._settings.sources)) full_pool = await self._build_pool() last_full = time.monotonic() self._available = list(full_pool) @@ -213,11 +247,19 @@ class ChainService: rotation_num += 1 self._notify({"type": "rotation", "n": rotation_num}) + log.info( + "Auto chain #%d: %d hops | obfuscation=%s | pool_remain=%d", + rotation_num, + len(chain), + self._settings.obfuscation_mode, + len(self._available), + ) await self._run_chain(gost, chain, real_ip) if self._stop.is_set(): break + log.info("Main loop exit (stop requested or fatal).") terminate_process(self._proc) self._proc = None @@ -232,6 +274,7 @@ class ChainService: Returns True if chain ran successfully, False if it immediately failed.""" self._current_chain = list(chain) self._notify({"type": "hops", "hops": chain, "status": "connecting"}) + self._notify({"type": "phase", "phase": "gost_start"}) listen = self._settings.listen_addr() cmd = build_gost_cmd(gost, listen, chain) self._notify({ @@ -239,11 +282,20 @@ class ChainService: "text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: " + " → ".join(self._short(h) for h in chain), }) + red = " | ".join(redact_proxy_url(h) for h in chain) + log.debug("GOST listen=http://%s | forwards (redacted): %s", listen, red) + log.debug("GOST argv: %s … (%d args)", cmd[0], len(cmd)) terminate_process(self._proc) self._proc = popen_no_window(cmd) + self._notify({"type": "log", "text": "GOST started — warming up (2s)…"}) - await asyncio.sleep(2.0) + for _ in range(4): + if self._stop.is_set(): + terminate_process(self._proc) + self._proc = None + return False + await asyncio.sleep(0.5) if self._proc.poll() is not None: # GOST died immediately — blacklist pool proxies (never blacklist user fixed exit) @@ -259,7 +311,14 @@ class ChainService: local_proxy = f"http://{listen}" timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0) + self._notify({"type": "phase", "phase": "verify_chain"}) + self._notify({ + "type": "log", + "text": f"Exit IP check through local proxy (≤{int(timeout * 2 + 5)}s)…", + }) + t0 = time.monotonic() exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout) + log.debug("Initial exit IP check took %.2fs → %s", time.monotonic() - t0, exit_ip or "none") if not exit_ip: self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None}) @@ -277,6 +336,7 @@ class ChainService: self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip}) self._notify({"type": "log", "text": f"✓ Chain healthy — Exit IP: {exit_ip}"}) + self._notify({"type": "phase", "phase": "running"}) set_system_proxy( self._settings.local_host, self._settings.local_port, @@ -285,9 +345,12 @@ class ChainService: self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"}) # ── Health monitor loop ─────────────────────────────────────────────── + hc = int(self._settings.health_check_seconds) while not self._stop.is_set(): + log.debug("Health sleep: %ds until next exit check", hc) result = await self._wait_health_interval() if result in ("stop", "rotate"): + log.debug("Health loop break: %s", result) break if self._proc is None or self._proc.poll() is not None: @@ -295,7 +358,9 @@ class ChainService: break self._notify({"type": "log", "text": "Health check..."}) + t1 = time.monotonic() exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout) + log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none") if not exit_ip or (real_ip and exit_ip == real_ip): self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip}) @@ -330,13 +395,24 @@ class ChainService: s = self._settings chain = self._pick_chain_for_mode(s.obfuscation_mode) if chain: + log.debug( + "Picked chain len=%d mode=%s available_left=%d", + len(chain), + s.obfuscation_mode, + len(self._available), + ) return chain if s.obfuscation_mode != "auto" and not s.use_pinned_chain: + log.debug("Pick empty under mode=%s — retrying as auto", s.obfuscation_mode) self._notify({ "type": "log", "text": "Obfuscation filter left nothing usable — retrying this pick with ALL protocols (auto).", }) - return self._pick_chain_for_mode("auto") + ch2 = self._pick_chain_for_mode("auto") + if ch2: + log.debug("Auto retry picked len=%d", len(ch2)) + return ch2 + log.debug("Pick chain returned empty (pool exhausted?)") return [] def _pick_chain_for_mode(self, mode: str) -> list[str]: @@ -380,6 +456,7 @@ class ChainService: """Fetch and validate proxies. Runs blocking I/O in thread pool.""" s = self._settings loop = asyncio.get_running_loop() + log.info("_build_pool: %d source URL(s), prefer_elite=%s", len(s.sources), s.prefer_elite) # Fetch all sources concurrently in thread pool (they are blocking) async def _fetch_one(url: str) -> list[str]: @@ -421,6 +498,7 @@ class ChainService: random.shuffle(unique) if len(unique) > s.max_candidates: unique = unique[: s.max_candidates] + log.debug("Unique candidates after dedupe/cap: %d (max_candidates=%d)", len(unique), s.max_candidates) self._notify({"type": "phase", "phase": "validate"}) self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."}) @@ -439,6 +517,8 @@ class ChainService: mex = normalize_proxy_url(s.manual_exit_proxy) if mex and not s.use_pinned_chain: + self._notify({"type": "phase", "phase": "exit_check"}) + self._notify({"type": "log", "text": "Validating fixed exit proxy…"}) v = await validate_proxies( [mex], s.ip_check_url, @@ -455,7 +535,7 @@ class ChainService: }) self._notify({"type": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"}) - self._notify({"type": "phase", "phase": "running"}) + log.info("_build_pool done: valid=%d / tested=%d", len(good), len(unique)) return good # ───────────────────────────────────────────────────────────────────────── @@ -488,6 +568,7 @@ class ChainService: @staticmethod def _short(url: str) -> str: + url = redact_proxy_url(url) url = ( url.replace("http://", "") .replace("socks5://", "s5://") diff --git a/proxy_chain_manager/validator.py b/proxy_chain_manager/validator.py index 9db74a2..1bbb6ca 100644 --- a/proxy_chain_manager/validator.py +++ b/proxy_chain_manager/validator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import json import logging +import time from typing import Callable import httpx @@ -51,35 +52,70 @@ async def validate_proxies( lock = asyncio.Lock() total = len(proxy_urls) done = 0 + c = max(1, concurrency) + waves = (total + c - 1) // c + cap = min(600.0, float(waves) * float(timeout_seconds) + 45.0) + cap = max(90.0, cap) + log.info( + "validate_proxies: n=%d concurrency=%d per_timeout=%.1fs wall_cap~=%.0fs check=%s", + total, + c, + timeout_seconds, + cap, + (check_url[:70] + "…") if len(check_url) > 70 else check_url, + ) - async def one(u: str) -> None: - nonlocal done - async with sem: - good = await _check_one(u, check_url, timeout_seconds) - async with lock: - done += 1 - if good: - ok.append(u) - if on_progress: - on_progress(done, total) - - await asyncio.gather(*(one(u) for u in proxy_urls)) - return ok - - -async def _check_one(proxy_url: str, check_url: str, timeout_seconds: float) -> bool: t = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds)) + lim = max(32, min(256, c * 8)) + limits = httpx.Limits(max_connections=lim, max_keepalive_connections=max(16, c * 4)) + + last_prog_t = 0.0 + prog_step = max(1, total // 120) + + async def _run_all(client: httpx.AsyncClient) -> None: + nonlocal done, last_prog_t + + async def one(u: str) -> None: + nonlocal done, last_prog_t + async with sem: + try: + r = await client.get(check_url, proxy=u) + good = r.status_code == 200 and len(r.content) > 0 + except Exception: + good = False + async with lock: + done += 1 + if good: + ok.append(u) + if on_progress: + now = time.monotonic() + if ( + done == total + or done == 1 + or done % prog_step == 0 + or (now - last_prog_t) >= 0.1 + ): + last_prog_t = now + on_progress(done, total) + + await asyncio.gather(*(one(u) for u in proxy_urls)) + try: async with httpx.AsyncClient( - proxy=proxy_url, timeout=t, - verify=False, # many proxies have self-signed or no TLS + verify=False, follow_redirects=True, - ) as c: - r = await c.get(check_url) - return r.status_code == 200 and len(r.content) > 0 - except Exception: - return False + limits=limits, + ) as client: + await asyncio.wait_for(_run_all(client), timeout=cap) + except asyncio.TimeoutError: + log.warning( + "validate_proxies wall-clock cap %.0fs exceeded (%d URLs) — returning partial results", + cap, + total, + ) + log.info("validate_proxies: finished ok=%d / %d", len(ok), total) + return ok async def check_chain_exit_ip( @@ -92,6 +128,12 @@ async def check_chain_exit_ip( per = max(5.0, min(20.0, float(timeout_seconds))) budget = max(15.0, min(60.0, float(timeout_seconds) * 2 + 5.0)) urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url][:2] + log.debug( + "check_chain_exit_ip: budget=%.1fs per_req=%.1fs fallback_count=%d", + budget, + per, + len(urls), + ) async def _run() -> str | None: t = httpx.Timeout(per, connect=min(8.0, per)) diff --git a/scripts/setup_and_build.ps1 b/scripts/setup_and_build.ps1 new file mode 100644 index 0000000..a844c97 --- /dev/null +++ b/scripts/setup_and_build.ps1 @@ -0,0 +1,63 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + After git pull: upgrade pip, install deps, build ProxyChainManager.exe, copy to Desktop, create "Proxy God.lnk". + +.DESCRIPTION + Requires Python 3.10+ on PATH (python.exe) or the Windows py launcher (py -3). + Does not auto-install Python; see README for winget one-liner if needed. +#> +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +Set-Location $root + +function Resolve-PythonExe { + if (Get-Command py -ErrorAction SilentlyContinue) { + try { + $out = (& py -3 -c "import sys; print(sys.executable)" 2>$null).Trim() + if ($out -and (Test-Path -LiteralPath $out)) { return $out } + } catch { } + } + $p = Get-Command python.exe -ErrorAction SilentlyContinue + if ($p) { return $p.Source } + throw ( + "Python 3.10+ not found (tried 'py -3' and 'python'). Install, then re-run:`n" + + " winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements`n" + + "Or https://www.python.org/downloads/ (enable 'Add python.exe to PATH')." + ) +} + +$py = Resolve-PythonExe +Write-Host "Using: $py" +& $py -c "import sys; assert sys.version_info >= (3, 10), 'Need Python 3.10+'; print(sys.version)" + +Write-Host "`n== pip (upgrade) ==" +& $py -m pip install --upgrade pip + +Write-Host "`n== dependencies + PyInstaller ==" +& $py -m pip install -r "$root\requirements.txt" +& $py -m pip install pyinstaller + +Write-Host "`n== PyInstaller ==" +& $py -m PyInstaller --noconfirm --clean ` + --onefile --windowed ` + --uac-admin ` + --name ProxyChainManager ` + --collect-all customtkinter ` + --hidden-import pystray._win32 ` + "$root\run.py" + +$distExe = Join-Path $root "dist\ProxyChainManager.exe" +if (-not (Test-Path $distExe)) { + throw "Build failed: missing $distExe" +} + +$desk = [Environment]::GetFolderPath("Desktop") +$deskExe = Join-Path $desk "ProxyChainManager.exe" +Copy-Item -LiteralPath $distExe -Destination $deskExe -Force +Write-Host "Copied: $deskExe" + +Write-Host "`n== Desktop shortcut ==" +& powershell -NoProfile -ExecutionPolicy Bypass -File "$root\scripts\create_desktop_shortcut.ps1" + +Write-Host "`nDone. Launch from Desktop: Proxy God.lnk (or ProxyChainManager.exe)" diff --git a/tests/test_proxy_god.py b/tests/test_proxy_god.py index 25e0605..bed1383 100644 --- a/tests/test_proxy_god.py +++ b/tests/test_proxy_god.py @@ -4,7 +4,14 @@ from __future__ import annotations import asyncio import unittest -from proxy_chain_manager.config import Settings, normalize_proxy_url, sanitize_settings +from proxy_chain_manager.config import ( + Settings, + merge_proxy_credentials, + normalize_proxy_url, + redact_proxy_url, + sanitize_settings, + split_proxy_for_edit, +) from proxy_chain_manager.fetcher import fetch_proxy_json, normalize_entries from proxy_chain_manager.validator import ( check_chain_exit_ip, @@ -19,6 +26,26 @@ class TestConfig(unittest.TestCase): self.assertEqual(normalize_proxy_url("1.2.3.4:8080"), "http://1.2.3.4:8080") self.assertEqual(normalize_proxy_url("socks5://x:1"), "socks5://x:1") + def test_merge_split_proxy_auth(self) -> None: + merged = merge_proxy_credentials("http://1.2.3.4:8080", "user", "p:ass") + self.assertIn("user", merged) + self.assertIn("p%3Aass", merged) + base, u, pw = split_proxy_for_edit(merged) + self.assertEqual(base, "http://1.2.3.4:8080") + self.assertEqual(u, "user") + self.assertEqual(pw, "p:ass") + + def test_merge_preserves_embedded_auth_when_fields_empty(self) -> None: + raw = normalize_proxy_url("http://x:y@9.9.9.9:12") + self.assertEqual(merge_proxy_credentials(raw, "", ""), raw) + + def test_redact_proxy_url(self) -> None: + r = redact_proxy_url("http://alice:secret@proxy.example:8888") + self.assertIn("***:***@", r) + self.assertIn("proxy.example", r) + self.assertNotIn("secret", r) + self.assertNotIn("alice", r) + def test_sanitize_clamps_extremes(self) -> None: s = Settings( chain_length=0,