from __future__ import annotations import asyncio import logging import random import threading import time from collections.abc import Callable from typing import Any from .config import Settings, load_settings, 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 from .sysproxy import clear_system_proxy, set_system_proxy from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies log = logging.getLogger(__name__) Notify = Callable[[dict[str, Any]], None] def _filter_by_mode(pool: list[str], mode: str) -> list[str]: """Filter pool by obfuscation 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 class ChainService: """Background rotating proxy chain using GOST.""" def __init__(self, notify: Notify) -> None: self._notify = notify self._stop = threading.Event() self._force_rotate = threading.Event() self._thread: threading.Thread | None = None self._proc = None self._settings = load_settings() self._current_chain: list[str] = [] @property def settings(self) -> Settings: return self._settings @property def current_chain(self) -> list[str]: return list(self._current_chain) def update_settings(self, s: Settings) -> None: self._settings = s save_settings(s) def start(self) -> None: 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.start() def stop(self) -> None: self._stop.set() terminate_process(self._proc) self._proc = None if self._thread: self._thread.join(timeout=15) self._teardown_network() self._notify({"type": "state", "running": False}) 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."}) if is_admin() and self._settings.kill_switch_enabled: ok, msg = fw_disengage() self._notify({"type": "log", "text": msg}) self._notify({"type": "firewall", "engaged": False}) def _run_thread(self) -> None: try: asyncio.run(self._async_main()) except Exception: log.exception("service thread failed") self._notify({"type": "log", "text": "Fatal error in service thread (see log)."}) finally: terminate_process(self._proc) self._proc = None self._teardown_network() self._notify({"type": "state", "running": False}) async def _async_main(self) -> None: self._notify({"type": "state", "running": True}) 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."}) 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..."}) gost.unlink(missing_ok=True) try: gost = ensure_gost() except Exception as e: self._notify({"type": "log", "text": f"GOST re-download failed: {e}"}) return 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}"}) else: self._notify({"type": "log", "text": "Could not determine real IP — exit IP comparison disabled."}) # Engage firewall kill-switch if enabled 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": "firewall", "engaged": False}) else: self._notify({"type": "log", "text": "Kill-switch disabled in settings."}) self._notify({"type": "firewall", "engaged": False}) last_full = 0.0 pool: list[str] = [] while not self._stop.is_set(): now = time.monotonic() need_refresh = ( not 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: chain = list(self._settings.pinned_chain) await self._run_chain(gost, chain, real_ip) if self._stop.is_set(): break continue if need_refresh: self._notify({"type": "phase", "phase": "fetch"}) pool = await self._build_pool() last_full = time.monotonic() self._notify({"type": "pool", "count": len(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) last_full = 0.0 continue chain = self._pick_chain(filtered) 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.""" 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)}) 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" self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None}) self._notify({"type": "log", "text": f"GOST exited immediately: {err_msg}"}) return 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), ) 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."}) terminate_process(self._proc) self._proc = None return 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."}) terminate_process(self._proc) self._proc = None return self._notify({"type": "hops", "hops": chain, "status": "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, self._settings.proxy_bypass, ) 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"): break if 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), ) 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."}) break self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip}) terminate_process(self._proc) self._proc = None async def _wait_health_interval(self) -> str | None: total = float(self._settings.health_check_seconds) end = time.monotonic() + total while time.monotonic() < end: if self._stop.is_set(): return "stop" if self._force_rotate.is_set(): self._force_rotate.clear() self._notify({"type": "log", "text": "Manual rotate."}) return "rotate" await asyncio.sleep(0.2) 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) @staticmethod def _short(url: str) -> str: return ( url.replace("http://", "") .replace("socks5://", "s5://") .replace("socks4://", "s4://") .replace("https://", "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