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
This commit is contained in:
Dr Jones
2026-04-16 00:27:39 -07:00
parent 0316b1befc
commit 214d2d2ff7
9 changed files with 495 additions and 89 deletions

View File

@@ -40,15 +40,51 @@ YOU → VPN (outer tunnel) → Hop 1 → Hop 2 → … → Last hop → Internet
## Requirements ## Requirements
- **Windows 10/11 x64** - **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 - **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` isnt 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`** | **Doubleclick 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 ```cmd
git clone https://gitea.thetempleofdoom.com/drjones/proxy-god.git
cd proxy-god cd proxy-god
pip install -r requirements.txt pip install -r requirements.txt
python run.py 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 youre offline). Hits config sanitization, fetcher smoke, validator timeouts, and a live `get_direct_ip` check (skips if youre offline).
### Shortcut missing?
After a successful build, if **`Proxy God.lnk`** isnt 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`** | **Doubleclick 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 ## 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 youre picky—defaults are **solid** out of the box. 2. Tweak **Settings** if youre 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). 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**. 4. Close the window → **tray**; the engine **keeps running** until you **Quit**.

View File

@@ -1,22 +1,6 @@
@echo off @echo off
setlocal setlocal
cd /d "%~dp0" cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\setup_and_build.ps1"
python -m pip install -r requirements.txt if errorlevel 1 exit /b 1
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"
endlocal endlocal

View File

@@ -18,8 +18,11 @@ from .config import (
OBFUSCATION_MODES, OBFUSCATION_MODES,
Settings, Settings,
load_settings, load_settings,
merge_proxy_credentials,
normalize_proxy_url, normalize_proxy_url,
redact_proxy_url,
save_settings, save_settings,
split_proxy_for_edit,
) )
from .firewall import ( from .firewall import (
disengage as fw_disengage, 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 ────────────────────────────────────────────────────────────────── # ── palette ──────────────────────────────────────────────────────────────────
BG = "#0d0d1a" BG = "#0d0d1a"
CARD = "#12122a" CARD = "#12122a"
@@ -104,6 +122,7 @@ def main() -> None:
pulse_job["id"] = None pulse_job["id"] = None
def _short(u: str) -> str: def _short(u: str) -> str:
u = redact_proxy_url(u)
u = u.replace("http://", "").replace("socks5://", "s5://").replace("socks4://", "s4://").replace("https://", "") u = u.replace("http://", "").replace("socks5://", "s5://").replace("socks4://", "s4://").replace("https://", "")
return u[:28] + "" if len(u) > 30 else u 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 = ctk.CTkFrame(tab_live, fg_color=CARD, corner_radius=8)
log_frame.pack(fill="both", expand=True) log_frame.pack(fill="both", expand=True)
log_box = ctk.CTkTextbox( log_box = ctk.CTkTextbox(
log_frame, font=("Consolas", 11), log_frame, font=("Consolas", 10),
fg_color=BG, text_color=TEXT, fg_color=BG, text_color=TEXT,
scrollbar_button_color=ACCENT, 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: def _log(line: str) -> None:
log_box.insert("end", f"[{_ts()}] {line}\n") log_box.insert("end", f"[{_ts()}] {line}\n")
log_box.see("end") 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 ────────────────────────────────────────────────── # ─── TAB: CHAIN BUILDER ──────────────────────────────────────────────────
cb_split = ctk.CTkFrame(tab_chain, fg_color="transparent") cb_split = ctk.CTkFrame(tab_chain, fg_color="transparent")
cb_split.pack(fill="x", pady=(4, 8)) cb_split.pack(fill="x", pady=(4, 8))
@@ -492,11 +551,37 @@ def main() -> None:
height=28, height=28,
) )
exit_proxy_entry.pack(fill="x", padx=10, pady=(0, 4)) exit_proxy_entry.pack(fill="x", padx=10, pady=(0, 4))
if s.manual_exit_proxy: exit_auth_row = ctk.CTkFrame(exit_fix_frame, fg_color="transparent")
exit_proxy_entry.insert(0, s.manual_exit_proxy) 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( ctk.CTkLabel(
exit_fix_frame, exit_fix_frame,
text="Earlier hops still rotate randomly. Empty = fully automatic exit.\n" 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.", "Ignored while “Pin & use this chain” is enabled.",
font=(FONT, 9), font=(FONT, 9),
text_color=TEXT2, text_color=TEXT2,
@@ -574,10 +659,21 @@ def main() -> None:
add_row = ctk.CTkFrame(cb_right, fg_color="transparent") add_row = ctk.CTkFrame(cb_right, fg_color="transparent")
add_row.pack(fill="x", padx=12, pady=(0, 4)) add_row.pack(fill="x", padx=12, pady=(0, 4))
add_entry = ctk.CTkEntry( 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, font=(FONT, 10), fg_color=BG, border_color=ACCENT, height=28,
) )
add_entry.pack(side="left", fill="x", expand=True, padx=(0, 4)) 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: def _add_proxy() -> None:
v = add_entry.get().strip() v = add_entry.get().strip()
@@ -585,9 +681,17 @@ def main() -> None:
return return
if "://" not in v: if "://" not in v:
v = "http://" + 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) manual_chain.append(v)
_rebuild_chain_ui() _rebuild_chain_ui()
add_entry.delete(0, "end") 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") _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(), obfuscation_mode=mode_var.get(),
use_pinned_chain=bool(use_manual_var.get()), use_pinned_chain=bool(use_manual_var.get()),
pinned_chain=list(manual_chain), 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()))), health_check_seconds=min(3600, max(10, int(entries["health"].get().strip()))),
full_refresh_seconds=min(86400, max(60, int(entries["refresh"].get().strip()))), full_refresh_seconds=min(86400, max(60, int(entries["refresh"].get().strip()))),
validation_concurrency=max(1, int(entries["conc"].get().strip())), validation_concurrency=max(1, int(entries["conc"].get().strip())),
@@ -756,16 +864,33 @@ def main() -> None:
elif t == "phase": elif t == "phase":
ph = str(m.get("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": if ph == "validate":
prog_bar.configure(progress_color=YELLOW) prog_bar.configure(progress_color=YELLOW)
elif ph == "fetch": elif ph == "fetch":
prog_bar.configure(progress_color=ORANGE) prog_bar.configure(progress_color=ORANGE)
prog_bar.set(0.15) 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": elif ph == "running":
prog_bar.configure(progress_color=GREEN) prog_bar.configure(progress_color=GREEN)
prog_bar.set(1.0) prog_bar.set(1.0)
else: elif ph in ("idle", ""):
prog_bar.set(0) prog_bar.set(0)
elif t == "validate_progress": elif t == "validate_progress":
@@ -849,6 +974,7 @@ def main() -> None:
_log("Press START to fetch, validate and chain proxies.") _log("Press START to fetch, validate and chain proxies.")
_log("Use +/ in top bar to change hop count instantly.") _log("Use +/ in top bar to change hop count instantly.")
_log("Chain Builder → Fixed exit: set the last hop (optional); rest still rotates.") _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) _log("" * 60)
_pump() _pump()
root.mainloop() root.mainloop()

View File

@@ -3,9 +3,22 @@ from __future__ import annotations
import json import json
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from urllib.parse import quote, unquote, urlparse, urlunparse
from .paths import app_data_dir 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) # 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. # do not affect 127.0.0.1; avoid storing LAN IPs or hostnames for the listener.
LISTEN_HOST = "127.0.0.1" LISTEN_HOST = "127.0.0.1"
@@ -21,6 +34,69 @@ def normalize_proxy_url(raw: str) -> str:
return t.rstrip("/") 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_MODES = ["auto", "http_only", "socks5_only", "random_mix"]
OBFUSCATION_LABELS = { OBFUSCATION_LABELS = {
"auto": "Auto (best available)", "auto": "Auto (best available)",

View File

@@ -9,6 +9,7 @@ log = logging.getLogger(__name__)
def fetch_proxy_json(url: str, timeout: float = 45.0) -> list[dict[str, Any]]: 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: with httpx.Client(timeout=timeout, follow_redirects=True) as c:
r = c.get(url) r = c.get(url)
r.raise_for_status() r.raise_for_status()

View File

@@ -9,7 +9,13 @@ from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any 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 .fetcher import fetch_proxy_json, normalize_entries
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin 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 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] Notify = Callable[[dict[str, Any]], None]
# Thread pool for blocking I/O (proxy list fetching) so we don't stall asyncio # 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: class ChainService:
@@ -107,6 +113,14 @@ class ChainService:
async def _async_main(self) -> None: async def _async_main(self) -> None:
self._notify({"type": "state", "running": True}) 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 ─────────────────────────────────────────────────────── # ── GOST setup ───────────────────────────────────────────────────────
try: try:
@@ -146,6 +160,15 @@ class ChainService:
self._notify({"type": "log", "text": "Kill-switch disabled in settings."}) self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
self._notify({"type": "firewall", "engaged": False}) 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 ──────────────────────────────────────────────── # ── Main rotation loop ────────────────────────────────────────────────
full_pool: list[str] = [] full_pool: list[str] = []
last_full = 0.0 last_full = 0.0
@@ -157,12 +180,22 @@ class ChainService:
not full_pool not full_pool
or now - last_full >= float(self._settings.full_refresh_seconds) 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 # Pinned (manual) chain mode — skip pool management
if self._settings.use_pinned_chain and self._settings.pinned_chain: if self._settings.use_pinned_chain and self._settings.pinned_chain:
chain = list(self._settings.pinned_chain) chain = list(self._settings.pinned_chain)
rotation_num += 1 rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num}) 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) ok = await self._run_chain(gost, chain, real_ip)
if not ok: if not ok:
await self._sleep_interruptible(10) await self._sleep_interruptible(10)
@@ -185,6 +218,7 @@ class ChainService:
# ── Pool refresh ───────────────────────────────────────────────── # ── Pool refresh ─────────────────────────────────────────────────
if need_refresh: if need_refresh:
self._notify({"type": "phase", "phase": "fetch"}) self._notify({"type": "phase", "phase": "fetch"})
log.info("Pool refresh: fetching %d source(s)…", len(self._settings.sources))
full_pool = await self._build_pool() full_pool = await self._build_pool()
last_full = time.monotonic() last_full = time.monotonic()
self._available = list(full_pool) self._available = list(full_pool)
@@ -213,11 +247,19 @@ class ChainService:
rotation_num += 1 rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num}) 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) await self._run_chain(gost, chain, real_ip)
if self._stop.is_set(): if self._stop.is_set():
break break
log.info("Main loop exit (stop requested or fatal).")
terminate_process(self._proc) terminate_process(self._proc)
self._proc = None self._proc = None
@@ -232,6 +274,7 @@ class ChainService:
Returns True if chain ran successfully, False if it immediately failed.""" Returns True if chain ran successfully, False if it immediately failed."""
self._current_chain = list(chain) self._current_chain = list(chain)
self._notify({"type": "hops", "hops": chain, "status": "connecting"}) self._notify({"type": "hops", "hops": chain, "status": "connecting"})
self._notify({"type": "phase", "phase": "gost_start"})
listen = self._settings.listen_addr() listen = self._settings.listen_addr()
cmd = build_gost_cmd(gost, listen, chain) cmd = build_gost_cmd(gost, listen, chain)
self._notify({ self._notify({
@@ -239,11 +282,20 @@ class ChainService:
"text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: " "text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: "
+ "".join(self._short(h) for h in chain), + "".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) terminate_process(self._proc)
self._proc = popen_no_window(cmd) 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: if self._proc.poll() is not None:
# GOST died immediately — blacklist pool proxies (never blacklist user fixed exit) # GOST died immediately — blacklist pool proxies (never blacklist user fixed exit)
@@ -259,7 +311,14 @@ class ChainService:
local_proxy = f"http://{listen}" local_proxy = f"http://{listen}"
timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0) 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) 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: if not exit_ip:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None}) 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": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"✓ Chain 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( set_system_proxy(
self._settings.local_host, self._settings.local_host,
self._settings.local_port, self._settings.local_port,
@@ -285,9 +345,12 @@ class ChainService:
self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"}) self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"})
# ── Health monitor loop ─────────────────────────────────────────────── # ── Health monitor loop ───────────────────────────────────────────────
hc = int(self._settings.health_check_seconds)
while not self._stop.is_set(): while not self._stop.is_set():
log.debug("Health sleep: %ds until next exit check", hc)
result = await self._wait_health_interval() result = await self._wait_health_interval()
if result in ("stop", "rotate"): if result in ("stop", "rotate"):
log.debug("Health loop break: %s", result)
break break
if self._proc is None or self._proc.poll() is not None: if self._proc is None or self._proc.poll() is not None:
@@ -295,7 +358,9 @@ class ChainService:
break break
self._notify({"type": "log", "text": "Health check..."}) 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) 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): if not exit_ip or (real_ip and exit_ip == real_ip):
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip}) self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
@@ -330,13 +395,24 @@ class ChainService:
s = self._settings s = self._settings
chain = self._pick_chain_for_mode(s.obfuscation_mode) chain = self._pick_chain_for_mode(s.obfuscation_mode)
if chain: if chain:
log.debug(
"Picked chain len=%d mode=%s available_left=%d",
len(chain),
s.obfuscation_mode,
len(self._available),
)
return chain return chain
if s.obfuscation_mode != "auto" and not s.use_pinned_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({ self._notify({
"type": "log", "type": "log",
"text": "Obfuscation filter left nothing usable — retrying this pick with ALL protocols (auto).", "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 [] return []
def _pick_chain_for_mode(self, mode: str) -> list[str]: 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.""" """Fetch and validate proxies. Runs blocking I/O in thread pool."""
s = self._settings s = self._settings
loop = asyncio.get_running_loop() 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) # Fetch all sources concurrently in thread pool (they are blocking)
async def _fetch_one(url: str) -> list[str]: async def _fetch_one(url: str) -> list[str]:
@@ -421,6 +498,7 @@ class ChainService:
random.shuffle(unique) random.shuffle(unique)
if len(unique) > s.max_candidates: if len(unique) > s.max_candidates:
unique = 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": "phase", "phase": "validate"})
self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."}) self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."})
@@ -439,6 +517,8 @@ class ChainService:
mex = normalize_proxy_url(s.manual_exit_proxy) mex = normalize_proxy_url(s.manual_exit_proxy)
if mex and not s.use_pinned_chain: 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( v = await validate_proxies(
[mex], [mex],
s.ip_check_url, 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": "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 return good
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
@@ -488,6 +568,7 @@ class ChainService:
@staticmethod @staticmethod
def _short(url: str) -> str: def _short(url: str) -> str:
url = redact_proxy_url(url)
url = ( url = (
url.replace("http://", "") url.replace("http://", "")
.replace("socks5://", "s5://") .replace("socks5://", "s5://")

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import time
from typing import Callable from typing import Callable
import httpx import httpx
@@ -51,35 +52,70 @@ async def validate_proxies(
lock = asyncio.Lock() lock = asyncio.Lock()
total = len(proxy_urls) total = len(proxy_urls)
done = 0 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)) 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: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
proxy=proxy_url,
timeout=t, timeout=t,
verify=False, # many proxies have self-signed or no TLS verify=False,
follow_redirects=True, follow_redirects=True,
) as c: limits=limits,
r = await c.get(check_url) ) as client:
return r.status_code == 200 and len(r.content) > 0 await asyncio.wait_for(_run_all(client), timeout=cap)
except Exception: except asyncio.TimeoutError:
return False 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( 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))) per = max(5.0, min(20.0, float(timeout_seconds)))
budget = max(15.0, min(60.0, float(timeout_seconds) * 2 + 5.0)) 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] 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: async def _run() -> str | None:
t = httpx.Timeout(per, connect=min(8.0, per)) t = httpx.Timeout(per, connect=min(8.0, per))

View File

@@ -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)"

View File

@@ -4,7 +4,14 @@ from __future__ import annotations
import asyncio import asyncio
import unittest 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.fetcher import fetch_proxy_json, normalize_entries
from proxy_chain_manager.validator import ( from proxy_chain_manager.validator import (
check_chain_exit_ip, 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("1.2.3.4:8080"), "http://1.2.3.4:8080")
self.assertEqual(normalize_proxy_url("socks5://x:1"), "socks5://x:1") 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: def test_sanitize_clamps_extremes(self) -> None:
s = Settings( s = Settings(
chain_length=0, chain_length=0,