feat: fixed exit proxy (last hop only), optional pool-free single-hop mode, gitignore pycache
Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user