feat: fixed exit proxy (last hop only), optional pool-free single-hop mode, gitignore pycache
Made-with: Cursor
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
*.spec.bak
|
||||
.env
|
||||
.venv/
|
||||
@@ -14,6 +14,7 @@ YOU → NordVPN (OS tunnel) → Hop 1 → Hop 2 → Hop 3 → Internet
|
||||
- **Multi-hop GOST chains** — 2–8 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** — 2–8 hops
|
||||
- **Hop count slider** — 1–8 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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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