diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46c4f10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +dist/ +build/ +*.spec.bak +.env +.venv/ diff --git a/README.md b/README.md index 10685b9..8b4cf72 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/proxy_chain_manager/__pycache__/__init__.cpython-311.pyc b/proxy_chain_manager/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index f43352d..0000000 Binary files a/proxy_chain_manager/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/app.cpython-311.pyc b/proxy_chain_manager/__pycache__/app.cpython-311.pyc deleted file mode 100644 index b6bac95..0000000 Binary files a/proxy_chain_manager/__pycache__/app.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/config.cpython-311.pyc b/proxy_chain_manager/__pycache__/config.cpython-311.pyc deleted file mode 100644 index 0412589..0000000 Binary files a/proxy_chain_manager/__pycache__/config.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/fetcher.cpython-311.pyc b/proxy_chain_manager/__pycache__/fetcher.cpython-311.pyc deleted file mode 100644 index ab37927..0000000 Binary files a/proxy_chain_manager/__pycache__/fetcher.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/firewall.cpython-311.pyc b/proxy_chain_manager/__pycache__/firewall.cpython-311.pyc deleted file mode 100644 index b779530..0000000 Binary files a/proxy_chain_manager/__pycache__/firewall.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/gost_util.cpython-311.pyc b/proxy_chain_manager/__pycache__/gost_util.cpython-311.pyc deleted file mode 100644 index 8d9fb01..0000000 Binary files a/proxy_chain_manager/__pycache__/gost_util.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/paths.cpython-311.pyc b/proxy_chain_manager/__pycache__/paths.cpython-311.pyc deleted file mode 100644 index f563e37..0000000 Binary files a/proxy_chain_manager/__pycache__/paths.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/service.cpython-311.pyc b/proxy_chain_manager/__pycache__/service.cpython-311.pyc deleted file mode 100644 index ed573f4..0000000 Binary files a/proxy_chain_manager/__pycache__/service.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/sysproxy.cpython-311.pyc b/proxy_chain_manager/__pycache__/sysproxy.cpython-311.pyc deleted file mode 100644 index 87346d9..0000000 Binary files a/proxy_chain_manager/__pycache__/sysproxy.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/tray.cpython-311.pyc b/proxy_chain_manager/__pycache__/tray.cpython-311.pyc deleted file mode 100644 index b7c95e2..0000000 Binary files a/proxy_chain_manager/__pycache__/tray.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/validator.cpython-311.pyc b/proxy_chain_manager/__pycache__/validator.cpython-311.pyc deleted file mode 100644 index ac0ec86..0000000 Binary files a/proxy_chain_manager/__pycache__/validator.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/__pycache__/windows_task.cpython-311.pyc b/proxy_chain_manager/__pycache__/windows_task.cpython-311.pyc deleted file mode 100644 index ce8adca..0000000 Binary files a/proxy_chain_manager/__pycache__/windows_task.cpython-311.pyc and /dev/null differ diff --git a/proxy_chain_manager/app.py b/proxy_chain_manager/app.py index 47fd1ef..91c7b23 100644 --- a/proxy_chain_manager/app.py +++ b/proxy_chain_manager/app.py @@ -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() diff --git a/proxy_chain_manager/config.py b/proxy_chain_manager/config.py index 76ae7eb..ab5495a 100644 --- a/proxy_chain_manager/config.py +++ b/proxy_chain_manager/config.py @@ -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: diff --git a/proxy_chain_manager/service.py b/proxy_chain_manager/service.py index 95848d6..93f64e9 100644 --- a/proxy_chain_manager/service.py +++ b/proxy_chain_manager/service.py @@ -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