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:
74
README.md
74
README.md
@@ -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` 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
|
```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 you’re offline).
|
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
|
## 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.
|
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).
|
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**.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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://")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
63
scripts/setup_and_build.ps1
Normal file
63
scripts/setup_and_build.ps1
Normal 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)"
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user