feat: fixed exit proxy (last hop only), optional pool-free single-hop mode, gitignore pycache

Made-with: Cursor
This commit is contained in:
Dr Jones
2026-04-14 19:28:50 -07:00
parent 45798464e4
commit 644229719f
17 changed files with 140 additions and 17 deletions

View File

@@ -9,7 +9,7 @@ from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from .config import Settings, load_settings, save_settings
from .config import Settings, load_settings, normalize_proxy_url, 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
@@ -52,6 +52,10 @@ class ChainService:
# Per-session blacklist: proxies that crashed GOST immediately
self._blacklist: set[str] = set()
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
@@ -176,6 +180,18 @@ class ChainService:
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"})
@@ -240,8 +256,11 @@ class ChainService:
await asyncio.sleep(2.0)
if self._proc.poll() is not None:
# GOST died immediately — blacklist these proxies
# 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})
@@ -305,17 +324,38 @@ class ChainService:
# ─────────────────────────────────────────────────────────────────────────
def _pick_chain(self) -> list[str]:
"""Pick chain_length proxies from _available (shuffle-and-drain).
Filters out blacklisted proxies. Marks picked ones as used."""
"""Pick chain_length hops: optional fixed last hop + random prefix from pool."""
s = self._settings
k = max(1, s.chain_length)
manual = self._manual_exit_url()
if manual and not s.use_pinned_chain:
# Last hop is always manual; earlier hops come from pool (never duplicate manual)
mid_need = k - 1
candidates = [
u for u in self._available
if u not in self._blacklist and u != manual
]
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)
# 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 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]
# Default: all hops from rotating pool
candidates = [u for u in self._available if u not in self._blacklist]
if s.obfuscation_mode == "http_only":
candidates = [u for u in candidates if u.startswith("http://")]
elif s.obfuscation_mode == "socks5_only":
@@ -327,7 +367,6 @@ class ChainService:
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)
@@ -386,6 +425,24 @@ class ChainService:
on_progress=on_prog,
)
random.shuffle(good)
mex = normalize_proxy_url(s.manual_exit_proxy)
if mex and not s.use_pinned_chain:
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)}"})
self._notify({"type": "phase", "phase": "running"})
return good