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:
@@ -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://")
|
||||
|
||||
Reference in New Issue
Block a user