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
- **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` 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
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 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
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.
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**.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)
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
nonlocal done, last_prog_t
async with sem:
good = await _check_one(u, check_url, timeout_seconds)
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))
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))
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))

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 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,