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

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
dist/
build/
*.spec.bak
.env
.venv/

View File

@@ -14,6 +14,7 @@ YOU → NordVPN (OS tunnel) → Hop 1 → Hop 2 → Hop 3 → Internet
- **Multi-hop GOST chains** — 28 hops, randomly picked from validated pool
- **Obfuscation modes** — Auto, HTTP-only, SOCKS5-only, Random Mix
- **Manual chain builder** — drag/reorder hops, pin your own IPs
- **Fixed exit (last hop)** — optional URL used only as the final hop; earlier hops still rotate randomly (ignored when a full manual chain is pinned)
- **Exit IP verification** — checks real exit IP every health cycle, rotates if it matches your real IP
- **Windows system proxy** — sets `HKCU` registry proxy so all WinINet apps use the chain
- **Firewall kill-switch** — `netsh` rules that block all outbound except GOST + NordVPN when running as Admin
@@ -73,7 +74,8 @@ Output: `dist\ProxyChainManager.exe` + copied to Desktop.
## Chain Builder Tab
- **Obfuscation mode** — selects which protocols to include in chains
- **Hop count slider** — 28 hops
- **Hop count slider** — 18 total hops (top bar ± or slider)
- **Fixed exit (last hop only)** — optional field: your SOCKS/HTTP proxy is always the **last** hop before the internet; all **earlier** hops are still chosen at random from the validated pool. Leave empty for a fully automatic chain. Set hop count to `1` to use **only** your fixed exit (no pool). While **Pin & use this chain** is on, the full pinned list wins and fixed exit is ignored.
- **Manual chain** — enter proxies manually, reorder with ↑↓, enable "Use this chain" to pin it
- **Paste current** — copies the auto-selected chain into the editor
- **Sources** — add/remove proxy list URLs (JSON format from Proxifly)

View File

@@ -18,6 +18,7 @@ from .config import (
OBFUSCATION_MODES,
Settings,
load_settings,
normalize_proxy_url,
save_settings,
)
from .firewall import (
@@ -176,6 +177,7 @@ def main() -> None:
obfuscation_mode=cur.obfuscation_mode,
use_pinned_chain=cur.use_pinned_chain,
pinned_chain=cur.pinned_chain,
manual_exit_proxy=cur.manual_exit_proxy,
health_check_seconds=cur.health_check_seconds,
full_refresh_seconds=cur.full_refresh_seconds,
validation_concurrency=cur.validation_concurrency,
@@ -273,7 +275,9 @@ def main() -> None:
d.configure(text_color=c)
pulse_job["id"] = root.after(380, lambda s=step: _pulse(s + 1)) # type: ignore[arg-type]
def _render_chain(hops: list[str], status: str, exit_ip: str | None) -> None:
def _render_chain(
hops: list[str], status: str, exit_ip: str | None, fixed_last: bool = False
) -> None:
_cancel_pulse()
for w in list(chain_canvas.winfo_children()):
w.destroy()
@@ -296,11 +300,15 @@ def main() -> None:
_node("NORD", PURPLE, bold=True)
_arr()
last_i = len(hops) - 1
for i, h in enumerate(hops):
dot = ctk.CTkLabel(chain_canvas, text="", font=(FONT, 12), text_color=hop_c)
is_fixed = fixed_last and i == last_i and last_i >= 0
lbl_c = ORANGE if is_fixed else hop_c
dot = ctk.CTkLabel(chain_canvas, text="", font=(FONT, 12), text_color=lbl_c)
dot.pack(side="left", padx=1)
hop_dot_labels.append(dot)
ctk.CTkLabel(chain_canvas, text=_short(h), font=(FONT, 10), text_color=hop_c).pack(side="left", padx=1)
label = _short(h) + ("" if is_fixed else "")
ctk.CTkLabel(chain_canvas, text=label, font=(FONT, 10), text_color=lbl_c).pack(side="left", padx=1)
if i < len(hops) - 1:
_arr()
@@ -448,7 +456,35 @@ def main() -> None:
cb_left, text="Elite proxies only (slower but more anonymous)",
variable=elite_var, font=(FONT, 11),
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
).pack(anchor="w", padx=16, pady=(0, 12))
).pack(anchor="w", padx=16, pady=(0, 8))
exit_fix_frame = ctk.CTkFrame(cb_left, fg_color=PANEL, corner_radius=8)
exit_fix_frame.pack(fill="x", padx=12, pady=(0, 12))
ctk.CTkLabel(
exit_fix_frame,
text="Fixed exit — last hop only",
font=(FONT, 11, "bold"),
text_color=ORANGE,
).pack(anchor="w", padx=10, pady=(8, 2))
exit_proxy_entry = ctk.CTkEntry(
exit_fix_frame,
placeholder_text="http://host:port or socks5://host:port (optional)",
font=("Consolas", 10),
fg_color=BG,
border_color=ACCENT,
height=28,
)
exit_proxy_entry.pack(fill="x", padx=10, pady=(0, 4))
if s.manual_exit_proxy:
exit_proxy_entry.insert(0, s.manual_exit_proxy)
ctk.CTkLabel(
exit_fix_frame,
text="Earlier hops still rotate randomly. Empty = fully automatic exit.\n"
"Ignored while “Pin & use this chain” is enabled.",
font=(FONT, 9),
text_color=TEXT2,
justify="left",
).pack(anchor="w", padx=10, pady=(0, 8))
# Right — manual chain editor
cb_right = ctk.CTkFrame(cb_split, fg_color=CARD, corner_radius=8, width=400)
@@ -643,6 +679,7 @@ def main() -> None:
obfuscation_mode=mode_var.get(),
use_pinned_chain=bool(use_manual_var.get()),
pinned_chain=list(manual_chain),
manual_exit_proxy=normalize_proxy_url(exit_proxy_entry.get()),
health_check_seconds=max(10, int(entries["health"].get().strip())),
full_refresh_seconds=max(60, int(entries["refresh"].get().strip())),
validation_concurrency=max(1, int(entries["conc"].get().strip())),
@@ -751,7 +788,9 @@ def main() -> None:
hops = [str(x) for x in (m.get("hops") or [])]
status = str(m.get("status", "connecting"))
exit_ip = m.get("exit_ip")
_render_chain(hops, status, exit_ip)
fix = normalize_proxy_url(svc.settings.manual_exit_proxy)
fixed_last = bool(fix) and not svc.settings.use_pinned_chain
_render_chain(hops, status, exit_ip, fixed_last=fixed_last)
_refresh_sysproxy()
tray.set_state(
{"healthy": "green", "dead": "red", "connecting": "yellow"}.get(status, "yellow")
@@ -792,6 +831,7 @@ def main() -> None:
_log(f"Log file: {LOG_PATH}")
_log("Press START to fetch, validate and chain proxies.")
_log("Use +/ in top bar to change hop count instantly.")
_log("Chain Builder → Fixed exit: set the last hop (optional); rest still rotates.")
_log("" * 60)
_pump()
root.mainloop()

View File

@@ -10,6 +10,17 @@ from .paths import app_data_dir
# do not affect 127.0.0.1; avoid storing LAN IPs or hostnames for the listener.
LISTEN_HOST = "127.0.0.1"
def normalize_proxy_url(raw: str) -> str:
"""Strip, add http:// if missing. Empty input → empty string."""
t = (raw or "").strip()
if not t:
return ""
if "://" not in t:
t = "http://" + t
return t.rstrip("/")
OBFUSCATION_MODES = ["auto", "http_only", "socks5_only", "random_mix"]
OBFUSCATION_LABELS = {
"auto": "Auto (best available)",
@@ -30,6 +41,8 @@ class Settings:
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
use_pinned_chain: bool = False # use manually ordered chain
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
# Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins)
manual_exit_proxy: str = ""
# ── timing ────────────────────────────────────────────────────────────
health_check_seconds: int = 180 # how often to re-verify exit IP
@@ -88,6 +101,8 @@ def load_settings() -> Settings:
s.obfuscation_mode = "auto"
if not isinstance(s.pinned_chain, list):
s.pinned_chain = []
if not hasattr(s, "manual_exit_proxy") or not isinstance(s.manual_exit_proxy, str):
s.manual_exit_proxy = ""
s, changed = normalize_listen_host(s)
if changed:
try:

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