Files
proxy-god/proxy_chain_manager/service.py
Indiana Holmes 6561bdc37f Add LAN-broadcast lockdown, per-rotation MAC, leak audit panel
Tier-1 paranoid hardening: privacy_lan.py disables LLMNR/NetBIOS/mDNS with reversible snapshot; service rotates MAC on every chain rotation when enabled; leak_audit.py probes every leak surface (IP, DNS, IPv6, WPAD, GPO, ProxySettingsPerUser, LAN broadcast, VPN, WebRTC) and renders pass/fail in Privacy tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:40:53 -07:00

790 lines
34 KiB
Python

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 .privacy_lan import (
LanSnapshot,
engage_lan_lockdown,
restore_lan,
)
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
self._lan_snap: LanSnapshot | None = None
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."})
if s.lan_lockdown_enabled and is_admin():
snap, logs = engage_lan_lockdown()
self._lan_snap = snap
for ln in logs:
self._notify({"type": "log", "text": f"LAN: {ln}"})
elif s.lan_lockdown_enabled:
self._notify({"type": "log", "text": "LAN lockdown 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."})
if self._lan_snap is not None and is_admin():
for ln in restore_lan(self._lan_snap):
self._notify({"type": "log", "text": f"LAN: {ln}"})
self._lan_snap = None
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")
# Per-rotation MAC re-randomization (only if mac_spoof is also on, since
# without spoof there's no original snapshot we own to mutate).
if (
self._settings.mac_rotate_on_chain_rotate
and self._settings.mac_spoof_enabled
and is_admin()
and self._mac_originals
):
_, logs = spoof_all_physical(self._mac_originals)
for ln in logs:
self._notify({"type": "log", "text": f"MAC rotate: {ln}"})
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