Files
proxy-god/proxy_chain_manager/service.py

339 lines
13 KiB
Python

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