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:
@@ -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()
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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://")
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
@@ -51,35 +52,70 @@ async def validate_proxies(
|
||||
lock = asyncio.Lock()
|
||||
total = len(proxy_urls)
|
||||
done = 0
|
||||
c = max(1, concurrency)
|
||||
waves = (total + c - 1) // c
|
||||
cap = min(600.0, float(waves) * float(timeout_seconds) + 45.0)
|
||||
cap = max(90.0, cap)
|
||||
log.info(
|
||||
"validate_proxies: n=%d concurrency=%d per_timeout=%.1fs wall_cap~=%.0fs check=%s",
|
||||
total,
|
||||
c,
|
||||
timeout_seconds,
|
||||
cap,
|
||||
(check_url[:70] + "…") if len(check_url) > 70 else check_url,
|
||||
)
|
||||
|
||||
async def one(u: str) -> None:
|
||||
nonlocal done
|
||||
async with sem:
|
||||
good = await _check_one(u, check_url, timeout_seconds)
|
||||
async with lock:
|
||||
done += 1
|
||||
if good:
|
||||
ok.append(u)
|
||||
if on_progress:
|
||||
on_progress(done, total)
|
||||
|
||||
await asyncio.gather(*(one(u) for u in proxy_urls))
|
||||
return ok
|
||||
|
||||
|
||||
async def _check_one(proxy_url: str, check_url: str, timeout_seconds: float) -> bool:
|
||||
t = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds))
|
||||
lim = max(32, min(256, c * 8))
|
||||
limits = httpx.Limits(max_connections=lim, max_keepalive_connections=max(16, c * 4))
|
||||
|
||||
last_prog_t = 0.0
|
||||
prog_step = max(1, total // 120)
|
||||
|
||||
async def _run_all(client: httpx.AsyncClient) -> None:
|
||||
nonlocal done, last_prog_t
|
||||
|
||||
async def one(u: str) -> None:
|
||||
nonlocal done, last_prog_t
|
||||
async with sem:
|
||||
try:
|
||||
r = await client.get(check_url, proxy=u)
|
||||
good = r.status_code == 200 and len(r.content) > 0
|
||||
except Exception:
|
||||
good = False
|
||||
async with lock:
|
||||
done += 1
|
||||
if good:
|
||||
ok.append(u)
|
||||
if on_progress:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
done == total
|
||||
or done == 1
|
||||
or done % prog_step == 0
|
||||
or (now - last_prog_t) >= 0.1
|
||||
):
|
||||
last_prog_t = now
|
||||
on_progress(done, total)
|
||||
|
||||
await asyncio.gather(*(one(u) for u in proxy_urls))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
proxy=proxy_url,
|
||||
timeout=t,
|
||||
verify=False, # many proxies have self-signed or no TLS
|
||||
verify=False,
|
||||
follow_redirects=True,
|
||||
) as c:
|
||||
r = await c.get(check_url)
|
||||
return r.status_code == 200 and len(r.content) > 0
|
||||
except Exception:
|
||||
return False
|
||||
limits=limits,
|
||||
) as client:
|
||||
await asyncio.wait_for(_run_all(client), timeout=cap)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning(
|
||||
"validate_proxies wall-clock cap %.0fs exceeded (%d URLs) — returning partial results",
|
||||
cap,
|
||||
total,
|
||||
)
|
||||
log.info("validate_proxies: finished ok=%d / %d", len(ok), total)
|
||||
return ok
|
||||
|
||||
|
||||
async def check_chain_exit_ip(
|
||||
@@ -92,6 +128,12 @@ async def check_chain_exit_ip(
|
||||
per = max(5.0, min(20.0, float(timeout_seconds)))
|
||||
budget = max(15.0, min(60.0, float(timeout_seconds) * 2 + 5.0))
|
||||
urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url][:2]
|
||||
log.debug(
|
||||
"check_chain_exit_ip: budget=%.1fs per_req=%.1fs fallback_count=%d",
|
||||
budget,
|
||||
per,
|
||||
len(urls),
|
||||
)
|
||||
|
||||
async def _run() -> str | None:
|
||||
t = httpx.Timeout(per, connect=min(8.0, per))
|
||||
|
||||
Reference in New Issue
Block a user