from __future__ import annotations import asyncio import logging 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, normalize_proxy_url, redact_proxy_url, save_settings, ) from .dns_leak import flush_dns_cache from .fetcher import fetch_proxy_json, normalize_entries from .fingerprint import ( apply_webrtc_hardening, disable_ipv6_on_adapters, enable_ipv6_on_adapters, get_computer_name, random_hostname, set_computer_name, ) 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, read_gost_log_tail, terminate_process from .leak_detect import is_chain_leak, leak_reason from .mac_spoof import restore_macs, spoof_all_physical from .sysproxy import ( clear_system_proxy, detect_policy_overrides, diagnose_system_proxy, is_system_proxy_set, set_system_proxy, ) from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies from .vpn_detect import VpnStatus, detect_vpn from .win_compat import probe as _win_probe 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=8, thread_name_prefix="fetcher") # Back-compat for tests from .leak_detect import is_same_subnet as _is_same_network # noqa: F401 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] = [] # 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() self._vpn: VpnStatus = VpnStatus() self._mac_originals: dict[str, str] = {} self._hostname_original: str | None = None self._ipv6_adapters: list[str] = [] self._webrtc_was_applied: bool = False def _manual_exit_url(self) -> str | None: u = normalize_proxy_url(self._settings.manual_exit_proxy) return u if u else None @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 compat = _win_probe() self._notify({"type": "log", "text": f"Host: {compat.summary()}"}) if not compat.has_net_cmdlets: self._notify({ "type": "log", "text": ( "Legacy PowerShell detected — MAC spoof / IPv6 disable / " "hostname spoof unavailable on this host. Proxy chain + " "kill-switch + WebRTC policy still work." ), }) 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."}) self._restore_privacy() 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 _apply_privacy(self) -> None: s = self._settings if s.mac_spoof_enabled and is_admin(): self._mac_originals, logs = spoof_all_physical(self._mac_originals or None) for ln in logs: self._notify({"type": "log", "text": f"MAC: {ln}"}) elif s.mac_spoof_enabled: self._notify({"type": "log", "text": "MAC spoof enabled but not Admin — skipped."}) if s.spoof_hostname_enabled and is_admin(): self._hostname_original = get_computer_name() new_name = random_hostname() ok, msg = set_computer_name(new_name) self._notify({"type": "log", "text": f"Hostname: {msg}"}) elif s.spoof_hostname_enabled: self._notify({"type": "log", "text": "Hostname spoof enabled but not Admin — skipped."}) if s.disable_ipv6_while_active and is_admin(): self._ipv6_adapters, logs = disable_ipv6_on_adapters() for ln in logs: self._notify({"type": "log", "text": ln}) elif s.disable_ipv6_while_active: self._notify({"type": "log", "text": "IPv6 disable enabled but not Admin — skipped."}) if s.harden_webrtc_enabled and is_admin(): ok, msg = apply_webrtc_hardening(True) self._webrtc_was_applied = ok self._notify({"type": "log", "text": msg}) elif s.harden_webrtc_enabled: self._notify({"type": "log", "text": "WebRTC hardening enabled but not Admin — skipped."}) def _restore_privacy(self) -> None: if self._mac_originals: for ln in restore_macs(self._mac_originals): self._notify({"type": "log", "text": f"MAC restore: {ln}"}) self._mac_originals.clear() if self._hostname_original and is_admin(): ok, msg = set_computer_name(self._hostname_original) self._notify({"type": "log", "text": f"Hostname restore: {msg}"}) self._hostname_original = None if self._ipv6_adapters: for ln in enable_ipv6_on_adapters(self._ipv6_adapters): self._notify({"type": "log", "text": ln}) self._ipv6_adapters.clear() if self._webrtc_was_applied and is_admin(): apply_webrtc_hardening(False) self._webrtc_was_applied = False self._notify({"type": "log", "text": "WebRTC policy restored."}) 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}) # ───────────────────────────────────────────────────────────────────────── # MAIN ASYNC LOOP # ───────────────────────────────────────────────────────────────────────── 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: 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}"}) return if gost.stat().st_size < 10_000: self._notify({"type": "log", "text": "GOST appears 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 # ── VPN + direct IP ─────────────────────────────────────────────────── self._vpn = detect_vpn() mode = "VPN-aware (/16)" if self._vpn.active else "strict (exact IP)" self._notify({ "type": "vpn", "active": self._vpn.active, "label": self._vpn.label, "adapter": self._vpn.adapter, "leak_mode": mode, }) self._notify({ "type": "log", "text": ( f"VPN: {self._vpn.label}" + (f" ({self._vpn.adapter})" if self._vpn.adapter else "") + f" — leak check: {mode}" ), }) real_ip = await get_direct_ip(self._settings.ip_check_url) if real_ip: self._notify({"type": "real_ip", "ip": real_ip}) label = "direct/VPN IP" if self._vpn.active else "your real IP" self._notify({"type": "log", "text": f"{label.capitalize()}: {real_ip}"}) else: self._notify({"type": "log", "text": "Could not determine direct IP — leak detection disabled."}) self._apply_privacy() # ── 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 Admin)."}) self._notify({"type": "firewall", "engaged": False}) else: 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 rotation_num = 0 while not self._stop.is_set(): now = time.monotonic() need_refresh = ( 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) if self._stop.is_set(): break continue # Fixed exit only: hop count = 1 → chain is only the manual exit (no pool) mex = self._manual_exit_url() if mex and not self._settings.use_pinned_chain and self._settings.chain_length == 1: rotation_num += 1 self._notify({"type": "rotation", "n": rotation_num}) ok = await self._run_chain(gost, [mex], real_ip) if not ok: await self._sleep_interruptible(15) if self._stop.is_set(): break continue # ── 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) random.shuffle(self._available) self._used.clear() self._notify({"type": "pool", "count": len(full_pool)}) if not full_pool: self._notify({"type": "log", "text": "Empty pool — retrying in 60s..."}) await self._sleep_interruptible(60) last_full = 0.0 continue # ── 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}) 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 # ───────────────────────────────────────────────────────────────────────── # 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"}) self._notify({"type": "phase", "phase": "gost_start"}) if self._settings.flush_dns_on_rotate: ok, msg = flush_dns_cache() if ok: log.debug("DNS cache flushed before chain run") listen = self._settings.listen_addr() cmd = build_gost_cmd(gost, listen, 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), }) 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)…"}) 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) fixed = self._manual_exit_url() for h in chain: if fixed and h == fixed: continue 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": "GOST exited immediately — proxies blacklisted."}) tail = read_gost_log_tail(20) if tail.strip(): for ln in tail.splitlines()[-8:]: if ln.strip(): self._notify({"type": "log", "text": f" GOST> {ln.rstrip()}"}) return False 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, chain_hops=len(chain) ) 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}) self._notify({"type": "log", "text": "Chain IP check failed. Rotating."}) terminate_process(self._proc) self._proc = None return False if is_chain_leak(exit_ip, real_ip, self._vpn.active): reason = leak_reason(exit_ip, real_ip, self._vpn.active) self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip}) self._notify({"type": "log", "text": f"Leak detected — {reason}. Rotating."}) log.warning("Chain leak: exit=%s real=%s vpn=%s — %s", exit_ip, real_ip, self._vpn.active, reason) # Blacklist the whole chain so we don't reuse broken proxies fixed = self._manual_exit_url() for h in chain: if fixed and h == fixed: continue self._blacklist.add(h) self._available = [x for x in self._available if x != h] terminate_process(self._proc) self._proc = None 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": "phase", "phase": "running"}) # Pre-flight: surface Group Policy locks (they will override us). pol_before = detect_policy_overrides() if pol_before: self._notify({"type": "log", "text": ( f"Group Policy proxy lock detected ({len(pol_before)} entries) — " "these BEAT our settings. Browsers will keep the policy proxy " "(or DIRECT) until those keys are removed." )}) for p in pol_before[:3]: self._notify({"type": "log", "text": f" ! {p}"}) set_system_proxy( self._settings.local_host, self._settings.local_port, self._settings.proxy_bypass, ) applied = is_system_proxy_set() self._notify({ "type": "log", "text": ( f"System proxy → {self._settings.listen_addr()} " f"(HKCU + HKLM(adm) + Connections + WinHTTP) " f"{'OK' if applied else 'FAILED — registry write rejected'}" ), }) # Post-flight diagnostic — every layer's actual state. for ln in diagnose_system_proxy(): self._notify({"type": "log", "text": f" proxy: {ln}"}) # ── 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: self._notify({"type": "log", "text": "GOST process died — rebuilding."}) 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, chain_hops=len(chain) ) log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none") if is_chain_leak(exit_ip, real_ip, self._vpn.active): reason = leak_reason(exit_ip, real_ip, self._vpn.active) self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip}) self._notify({"type": "log", "text": f"Health check failed ({reason}) — 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 # ───────────────────────────────────────────────────────────────────────── @staticmethod def _apply_mode_filter(candidates: list[str], mode: str) -> list[str]: if mode == "http_only": return [u for u in candidates if u.startswith("http://")] if mode == "socks5_only": return [u for u in candidates if u.startswith("socks5://")] if mode == "random_mix": out = list(candidates) random.shuffle(out) return out return list(candidates) def _pick_chain(self) -> list[str]: """Pick chain_length hops: optional fixed last hop + random prefix from pool.""" 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).", }) 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]: """Build one chain using given mode (http_only / socks5_only / random_mix / auto).""" s = self._settings k = max(1, s.chain_length) manual = self._manual_exit_url() if manual and not s.use_pinned_chain: mid_need = k - 1 base = [ u for u in self._available if u not in self._blacklist and u != manual ] candidates = self._apply_mode_filter(base, mode) if mid_need == 0: return [manual] if len(candidates) < mid_need: return [] prefix = candidates[:mid_need] pset = set(prefix) self._available = [u for u in self._available if u not in pset] self._used.update(prefix) self._used.add(manual) return prefix + [manual] base = [u for u in self._available if u not in self._blacklist] candidates = self._apply_mode_filter(base, mode) if len(candidates) < k: return [] picked = candidates[:k] 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_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]: try: rows = await asyncio.wait_for( loop.run_in_executor( _FETCH_POOL, lambda u=url: fetch_proxy_json(u, timeout=50.0), ), timeout=55.0, ) entries = normalize_entries(rows, s.prefer_elite) if s.prefer_elite and rows and not entries: self._notify({ "type": "log", "text": ( f"Source returned {len(rows)} proxies but none marked “elite” — all skipped. " "Turn off “Elite proxies only” in Chain Builder if every fetch is empty." ), }) self._notify({"type": "log", "text": f"Fetched {len(entries)} from source."}) return entries except asyncio.TimeoutError: self._notify({"type": "log", "text": f"Fetch timed out (55s): {url[:60]}..."}) return [] except Exception as e: self._notify({"type": "log", "text": f"Fetch error: {e!s}"}) return [] src_urls = list(s.sources) if s.sources else list(Settings().sources) if not s.sources: self._notify({ "type": "log", "text": "Settings had no proxy sources — using built-in defaults (save Settings to persist).", }) if not src_urls: self._notify({"type": "log", "text": "No proxy source URLs configured — cannot build a pool."}) return [] results = await asyncio.gather(*(_fetch_one(u) for u in src_urls)) 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 (check network, URLs, or turn off “Elite only” if every list is filtered empty).", }) 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] 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..."}) def on_prog(done: int, total: int) -> None: self._notify({"type": "validate_progress", "done": done, "total": total}) target = max(s.min_pool_size, s.chain_length * 3) good = await validate_proxies( unique, s.ip_check_url, s.validation_concurrency, s.validation_timeout_seconds, on_progress=on_prog, target=target, ) random.shuffle(good) 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, 1, s.validation_timeout_seconds, on_progress=None, ) if v: self._notify({"type": "log", "text": "Fixed exit proxy: OK (reachable)."}) else: self._notify({ "type": "log", "text": "Fixed exit proxy: validation failed — will still attempt; check URL/credentials.", }) self._notify({"type": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"}) log.info("_build_pool done: valid=%d / tested=%d", len(good), len(unique)) 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: if self._stop.is_set(): return "stop" if self._force_rotate.is_set(): self._force_rotate.clear() self._notify({"type": "log", "text": "Manual rotate triggered."}) if self._settings.flush_dns_on_rotate: ok, msg = flush_dns_cache() self._notify({"type": "log", "text": f"DNS flush: {msg}" if ok else f"DNS flush failed: {msg}"}) return "rotate" 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 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: url = redact_proxy_url(url) url = ( url.replace("http://", "") .replace("socks5://", "s5://") .replace("socks4://", "s4://") .replace("https://", "") ) return url[:30] + "…" if len(url) > 32 else url