optimize: shuffle-drain pool (no repeat chains), executor for fetchers, DEVNULL pipes, verify=False validator, IP fallbacks, +/- hop topbar, timestamps, countdown, rotation counter, blacklist bad proxies
Made-with: Cursor
This commit is contained in:
@@ -6,6 +6,7 @@ import random
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from .config import Settings, load_settings, save_settings
|
||||
@@ -19,18 +20,18 @@ 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")
|
||||
|
||||
|
||||
def _filter_by_mode(pool: list[str], mode: str) -> list[str]:
|
||||
"""Filter pool by obfuscation mode."""
|
||||
"""Filter pool by obfuscation/protocol mode."""
|
||||
if mode == "http_only":
|
||||
return [u for u in pool if u.startswith("http://")]
|
||||
if mode == "socks5_only":
|
||||
return [u for u in pool if u.startswith("socks5://")]
|
||||
if mode == "random_mix":
|
||||
random.shuffle(pool)
|
||||
return pool
|
||||
# auto — keep all
|
||||
return pool
|
||||
# "random_mix" and "auto" — keep all, caller will shuffle
|
||||
return list(pool)
|
||||
|
||||
|
||||
class ChainService:
|
||||
@@ -45,6 +46,12 @@ class ChainService:
|
||||
self._settings = load_settings()
|
||||
self._current_chain: list[str] = []
|
||||
|
||||
# Shuffle-and-drain pool state — never repeat a proxy within a cycle
|
||||
self._available: list[str] = [] # proxies not yet used this cycle
|
||||
self._used: set[str] = set() # proxies used this cycle
|
||||
# Per-session blacklist: proxies that crashed GOST immediately
|
||||
self._blacklist: set[str] = set()
|
||||
|
||||
@property
|
||||
def settings(self) -> Settings:
|
||||
return self._settings
|
||||
@@ -61,7 +68,9 @@ class ChainService:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run_thread, name="ChainService", daemon=True)
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_thread, name="ChainService", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
@@ -76,6 +85,8 @@ class ChainService:
|
||||
def rotate_now(self) -> None:
|
||||
self._force_rotate.set()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _teardown_network(self) -> None:
|
||||
clear_system_proxy()
|
||||
self._notify({"type": "log", "text": "System proxy cleared."})
|
||||
@@ -96,19 +107,23 @@ class ChainService:
|
||||
self._teardown_network()
|
||||
self._notify({"type": "state", "running": False})
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# MAIN ASYNC LOOP
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _async_main(self) -> None:
|
||||
self._notify({"type": "state", "running": True})
|
||||
|
||||
# ── GOST setup ───────────────────────────────────────────────────────
|
||||
try:
|
||||
gost = ensure_gost()
|
||||
self._notify({"type": "log", "text": f"GOST ready: {gost}"})
|
||||
except Exception as e:
|
||||
self._notify({"type": "log", "text": f"GOST setup failed: {e} — check internet connection."})
|
||||
self._notify({"type": "log", "text": f"GOST setup failed: {e}"})
|
||||
return
|
||||
|
||||
# Verify GOST isn't quarantined by checking file size
|
||||
if gost.stat().st_size < 10_000:
|
||||
self._notify({"type": "log", "text": "GOST exe looks invalid (may be quarantined). Re-downloading..."})
|
||||
self._notify({"type": "log", "text": "GOST appears quarantined. Re-downloading..."})
|
||||
gost.unlink(missing_ok=True)
|
||||
try:
|
||||
gost = ensure_gost()
|
||||
@@ -116,121 +131,143 @@ class ChainService:
|
||||
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
|
||||
return
|
||||
|
||||
# ── Real IP ──────────────────────────────────────────────────────────
|
||||
real_ip = await get_direct_ip(self._settings.ip_check_url)
|
||||
if real_ip:
|
||||
self._notify({"type": "real_ip", "ip": real_ip})
|
||||
self._notify({"type": "log", "text": f"Your IP (without chain): {real_ip}"})
|
||||
self._notify({"type": "log", "text": f"Your real IP: {real_ip}"})
|
||||
else:
|
||||
self._notify({"type": "log", "text": "Could not determine real IP — exit IP comparison disabled."})
|
||||
self._notify({"type": "log", "text": "Could not determine real IP — leak detection disabled."})
|
||||
|
||||
# Engage firewall kill-switch if enabled
|
||||
# ── Firewall kill-switch ──────────────────────────────────────────────
|
||||
if self._settings.kill_switch_enabled:
|
||||
if is_admin():
|
||||
ok, msg = fw_engage(gost)
|
||||
self._notify({"type": "log", "text": msg})
|
||||
self._notify({"type": "firewall", "engaged": ok})
|
||||
else:
|
||||
self._notify({"type": "log", "text": "Kill-switch skipped — not running as Administrator."})
|
||||
self._notify({"type": "log", "text": "Kill-switch skipped (not Admin)."})
|
||||
self._notify({"type": "firewall", "engaged": False})
|
||||
else:
|
||||
self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
|
||||
self._notify({"type": "firewall", "engaged": False})
|
||||
|
||||
# ── Main rotation loop ────────────────────────────────────────────────
|
||||
full_pool: list[str] = []
|
||||
last_full = 0.0
|
||||
pool: list[str] = []
|
||||
rotation_num = 0
|
||||
|
||||
while not self._stop.is_set():
|
||||
now = time.monotonic()
|
||||
need_refresh = (
|
||||
not pool
|
||||
not full_pool
|
||||
or now - last_full >= float(self._settings.full_refresh_seconds)
|
||||
)
|
||||
|
||||
# If using pinned chain, skip pool management
|
||||
if self._settings.use_pinned_chain and len(self._settings.pinned_chain) >= 1:
|
||||
# Pinned (manual) chain mode — skip pool management
|
||||
if self._settings.use_pinned_chain and self._settings.pinned_chain:
|
||||
chain = list(self._settings.pinned_chain)
|
||||
await self._run_chain(gost, chain, real_ip)
|
||||
rotation_num += 1
|
||||
self._notify({"type": "rotation", "n": rotation_num})
|
||||
ok = await self._run_chain(gost, chain, real_ip)
|
||||
if not ok:
|
||||
await self._sleep_interruptible(10)
|
||||
if self._stop.is_set():
|
||||
break
|
||||
continue
|
||||
|
||||
# ── Pool refresh ─────────────────────────────────────────────────
|
||||
if need_refresh:
|
||||
self._notify({"type": "phase", "phase": "fetch"})
|
||||
pool = await self._build_pool()
|
||||
full_pool = await self._build_pool()
|
||||
last_full = time.monotonic()
|
||||
self._notify({"type": "pool", "count": len(pool)})
|
||||
self._available = list(full_pool)
|
||||
random.shuffle(self._available)
|
||||
self._used.clear()
|
||||
self._notify({"type": "pool", "count": len(full_pool)})
|
||||
|
||||
filtered = _filter_by_mode(list(pool), self._settings.obfuscation_mode)
|
||||
|
||||
if len(filtered) < 2:
|
||||
self._notify({
|
||||
"type": "log",
|
||||
"text": (
|
||||
f"Pool has {len(filtered)} proxies for mode '{self._settings.obfuscation_mode}'. "
|
||||
"Try 'auto' mode or increase max candidates. Retrying..."
|
||||
)
|
||||
})
|
||||
await asyncio.sleep(30)
|
||||
if not full_pool:
|
||||
self._notify({"type": "log", "text": "Empty pool — retrying in 60s..."})
|
||||
await self._sleep_interruptible(60)
|
||||
last_full = 0.0
|
||||
continue
|
||||
|
||||
chain = self._pick_chain(filtered)
|
||||
# ── Pick next chain (shuffle-and-drain, no repeats per cycle) ────
|
||||
chain = self._pick_chain()
|
||||
if not chain:
|
||||
# Pool exhausted for this cycle — reshuffle and restart
|
||||
self._notify({"type": "log", "text": "Pool cycle complete — reshuffling for next round."})
|
||||
self._available = list(full_pool)
|
||||
random.shuffle(self._available)
|
||||
self._used.clear()
|
||||
chain = self._pick_chain()
|
||||
if not chain:
|
||||
await self._sleep_interruptible(15)
|
||||
continue
|
||||
|
||||
rotation_num += 1
|
||||
self._notify({"type": "rotation", "n": rotation_num})
|
||||
await self._run_chain(gost, chain, real_ip)
|
||||
|
||||
if self._stop.is_set():
|
||||
break
|
||||
|
||||
terminate_process(self._proc)
|
||||
self._proc = None
|
||||
|
||||
async def _run_chain(self, gost: Any, chain: list[str], real_ip: str | None) -> None:
|
||||
"""Spin up GOST with the given chain, monitor it, return when chain dies or rotation triggered."""
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# CHAIN RUNNER
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _run_chain(
|
||||
self, gost: Any, chain: list[str], real_ip: str | None
|
||||
) -> bool:
|
||||
"""Start GOST with chain, verify exit IP, monitor until rotation/stop.
|
||||
Returns True if chain ran successfully, False if it immediately failed."""
|
||||
self._current_chain = list(chain)
|
||||
self._notify({"type": "hops", "hops": chain, "status": "connecting"})
|
||||
listen = self._settings.listen_addr()
|
||||
cmd = build_gost_cmd(gost, listen, chain)
|
||||
self._notify({"type": "log", "text": "Starting: " + " → ".join(self._short(h) for h in chain)})
|
||||
self._notify({
|
||||
"type": "log",
|
||||
"text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: "
|
||||
+ " → ".join(self._short(h) for h in chain),
|
||||
})
|
||||
|
||||
terminate_process(self._proc)
|
||||
self._proc = popen_no_window(cmd)
|
||||
|
||||
# Brief settle time
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
# Check GOST didn't immediately die
|
||||
if self._proc.poll() is not None:
|
||||
stderr = b""
|
||||
try:
|
||||
_, stderr = self._proc.communicate(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
err_msg = stderr.decode(errors="replace").strip() if stderr else "unknown error"
|
||||
# GOST died immediately — blacklist these proxies
|
||||
for h in chain:
|
||||
self._blacklist.add(h)
|
||||
self._available = [x for x in self._available if x != h]
|
||||
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
|
||||
self._notify({"type": "log", "text": f"GOST exited immediately: {err_msg}"})
|
||||
return
|
||||
self._notify({"type": "log", "text": "GOST exited immediately — proxies blacklisted."})
|
||||
return False
|
||||
|
||||
local_proxy = f"http://{listen}"
|
||||
exit_ip = await check_chain_exit_ip(
|
||||
local_proxy,
|
||||
self._settings.ip_check_url,
|
||||
min(25.0, self._settings.validation_timeout_seconds + 10.0),
|
||||
)
|
||||
timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0)
|
||||
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
|
||||
|
||||
if not exit_ip:
|
||||
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
|
||||
self._notify({"type": "log", "text": "Chain failed IP check. Rotating."})
|
||||
self._notify({"type": "log", "text": "Chain IP check failed. Rotating."})
|
||||
terminate_process(self._proc)
|
||||
self._proc = None
|
||||
return
|
||||
return False
|
||||
|
||||
if real_ip and exit_ip == real_ip:
|
||||
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
|
||||
self._notify({"type": "log", "text": f"Exit IP {exit_ip} == real IP! Chain leaking. Rotating."})
|
||||
self._notify({"type": "log", "text": f"Leak! Exit={exit_ip} == real IP. Rotating."})
|
||||
terminate_process(self._proc)
|
||||
self._proc = None
|
||||
return
|
||||
return False
|
||||
|
||||
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}"})
|
||||
set_system_proxy(
|
||||
self._settings.local_host,
|
||||
self._settings.local_port,
|
||||
@@ -238,32 +275,127 @@ class ChainService:
|
||||
)
|
||||
self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"})
|
||||
|
||||
# Monitor loop
|
||||
refresh_deadline = time.monotonic() + float(self._settings.full_refresh_seconds)
|
||||
while not self._stop.is_set() and time.monotonic() < refresh_deadline:
|
||||
w = await self._wait_health_interval()
|
||||
if w in ("stop", "rotate"):
|
||||
# ── Health monitor loop ───────────────────────────────────────────────
|
||||
while not self._stop.is_set():
|
||||
result = await self._wait_health_interval()
|
||||
if result in ("stop", "rotate"):
|
||||
break
|
||||
|
||||
if self._proc.poll() is not None:
|
||||
self._notify({"type": "log", "text": "GOST process died; rebuilding."})
|
||||
if self._proc is None or self._proc.poll() is not None:
|
||||
self._notify({"type": "log", "text": "GOST process died — rebuilding."})
|
||||
break
|
||||
|
||||
exit_ip = await check_chain_exit_ip(
|
||||
local_proxy,
|
||||
self._settings.ip_check_url,
|
||||
min(25.0, self._settings.validation_timeout_seconds + 10.0),
|
||||
)
|
||||
self._notify({"type": "log", "text": "Health check..."})
|
||||
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
|
||||
|
||||
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": "log", "text": "Health check failed; rotating."})
|
||||
self._notify({"type": "log", "text": "Health check failed — rotating."})
|
||||
break
|
||||
|
||||
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
|
||||
self._notify({"type": "log", "text": f"✓ Still healthy — Exit IP: {exit_ip}"})
|
||||
|
||||
terminate_process(self._proc)
|
||||
self._proc = None
|
||||
return True
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# POOL MANAGEMENT
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pick_chain(self) -> list[str]:
|
||||
"""Pick chain_length proxies from _available (shuffle-and-drain).
|
||||
Filters out blacklisted proxies. Marks picked ones as used."""
|
||||
s = self._settings
|
||||
k = max(1, s.chain_length)
|
||||
|
||||
# Apply mode filter and remove blacklisted entries
|
||||
candidates = [
|
||||
u for u in self._available
|
||||
if u not in self._blacklist
|
||||
]
|
||||
# Apply obfuscation mode filter
|
||||
if s.obfuscation_mode == "http_only":
|
||||
candidates = [u for u in candidates if u.startswith("http://")]
|
||||
elif s.obfuscation_mode == "socks5_only":
|
||||
candidates = [u for u in candidates if u.startswith("socks5://")]
|
||||
elif s.obfuscation_mode == "random_mix":
|
||||
random.shuffle(candidates)
|
||||
|
||||
if len(candidates) < k:
|
||||
return []
|
||||
|
||||
picked = candidates[:k]
|
||||
# Remove picked from available
|
||||
picked_set = set(picked)
|
||||
self._available = [u for u in self._available if u not in picked_set]
|
||||
self._used.update(picked)
|
||||
return picked
|
||||
|
||||
async def _build_pool(self) -> list[str]:
|
||||
"""Fetch and validate proxies. Runs blocking I/O in thread pool."""
|
||||
s = self._settings
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Fetch all sources concurrently in thread pool (they are blocking)
|
||||
async def _fetch_one(url: str) -> list[str]:
|
||||
try:
|
||||
rows = await loop.run_in_executor(
|
||||
_FETCH_POOL, lambda: fetch_proxy_json(url)
|
||||
)
|
||||
entries = normalize_entries(rows, s.prefer_elite)
|
||||
self._notify({"type": "log", "text": f"Fetched {len(entries)} from source."})
|
||||
return entries
|
||||
except Exception as e:
|
||||
self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
|
||||
return []
|
||||
|
||||
results = await asyncio.gather(*(_fetch_one(u) for u in s.sources))
|
||||
raw_urls: list[str] = []
|
||||
for chunk in results:
|
||||
raw_urls.extend(chunk)
|
||||
|
||||
if not raw_urls:
|
||||
self._notify({"type": "log", "text": "No proxies fetched from any source!"})
|
||||
return []
|
||||
|
||||
# Deduplicate, remove blacklisted, shuffle before capping
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for u in raw_urls:
|
||||
if u not in seen and u not in self._blacklist:
|
||||
seen.add(u)
|
||||
unique.append(u)
|
||||
|
||||
random.shuffle(unique)
|
||||
if len(unique) > s.max_candidates:
|
||||
unique = unique[: s.max_candidates]
|
||||
|
||||
self._notify({"type": "phase", "phase": "validate"})
|
||||
self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."})
|
||||
|
||||
def on_prog(done: int, total: int) -> None:
|
||||
self._notify({"type": "validate_progress", "done": done, "total": total})
|
||||
|
||||
good = await validate_proxies(
|
||||
unique,
|
||||
s.ip_check_url,
|
||||
s.validation_concurrency,
|
||||
s.validation_timeout_seconds,
|
||||
on_progress=on_prog,
|
||||
)
|
||||
random.shuffle(good)
|
||||
self._notify({"type": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"})
|
||||
self._notify({"type": "phase", "phase": "running"})
|
||||
return good
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# UTILITIES
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _wait_health_interval(self) -> str | None:
|
||||
"""Sleep for health_check_seconds. Returns 'stop', 'rotate', or None."""
|
||||
total = float(self._settings.health_check_seconds)
|
||||
end = time.monotonic() + total
|
||||
while time.monotonic() < end:
|
||||
@@ -271,68 +403,27 @@ class ChainService:
|
||||
return "stop"
|
||||
if self._force_rotate.is_set():
|
||||
self._force_rotate.clear()
|
||||
self._notify({"type": "log", "text": "Manual rotate."})
|
||||
self._notify({"type": "log", "text": "Manual rotate triggered."})
|
||||
return "rotate"
|
||||
await asyncio.sleep(0.2)
|
||||
remaining = end - time.monotonic()
|
||||
self._notify({"type": "countdown", "secs": max(0, int(remaining))})
|
||||
await asyncio.sleep(1.0)
|
||||
self._notify({"type": "countdown", "secs": 0})
|
||||
return None
|
||||
|
||||
def _pick_chain(self, pool: list[str]) -> list[str]:
|
||||
k = min(self._settings.chain_length, len(pool))
|
||||
return random.sample(pool, k=k)
|
||||
async def _sleep_interruptible(self, seconds: float) -> None:
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
@staticmethod
|
||||
def _short(url: str) -> str:
|
||||
return (
|
||||
url = (
|
||||
url.replace("http://", "")
|
||||
.replace("socks5://", "s5://")
|
||||
.replace("socks4://", "s4://")
|
||||
.replace("https://", "https://")
|
||||
.replace("https://", "")
|
||||
)
|
||||
|
||||
async def _build_pool(self) -> list[str]:
|
||||
s = self._settings
|
||||
raw_urls: list[str] = []
|
||||
for url in s.sources:
|
||||
if self._stop.is_set():
|
||||
break
|
||||
try:
|
||||
rows = fetch_proxy_json(url)
|
||||
entries = normalize_entries(rows, s.prefer_elite)
|
||||
raw_urls.extend(entries)
|
||||
self._notify({"type": "log", "text": f"Fetched {len(rows)} entries from source."})
|
||||
except Exception as e:
|
||||
self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
|
||||
|
||||
if not raw_urls:
|
||||
self._notify({"type": "log", "text": "No proxies fetched from any source!"})
|
||||
return []
|
||||
|
||||
# Deduplicate
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for u in raw_urls:
|
||||
if u not in seen:
|
||||
seen.add(u)
|
||||
unique.append(u)
|
||||
raw_urls = unique
|
||||
|
||||
if len(raw_urls) > s.max_candidates:
|
||||
raw_urls = random.sample(raw_urls, k=s.max_candidates)
|
||||
|
||||
self._notify({"type": "phase", "phase": "validate"})
|
||||
self._notify({"type": "log", "text": f"Validating {len(raw_urls)} candidates..."})
|
||||
|
||||
def on_prog(done: int, total: int) -> None:
|
||||
self._notify({"type": "validate_progress", "done": done, "total": total})
|
||||
|
||||
good = await validate_proxies(
|
||||
raw_urls,
|
||||
s.ip_check_url,
|
||||
s.validation_concurrency,
|
||||
s.validation_timeout_seconds,
|
||||
on_progress=on_prog,
|
||||
)
|
||||
random.shuffle(good)
|
||||
self._notify({"type": "log", "text": f"Valid: {len(good)} / {len(raw_urls)}"})
|
||||
self._notify({"type": "phase", "phase": "running"})
|
||||
return good
|
||||
return url[:30] + "…" if len(url) > 32 else url
|
||||
|
||||
Reference in New Issue
Block a user