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
|
- **Multi-hop GOST chains** — 2–8 hops, randomly picked from validated pool
|
||||||
- **Obfuscation modes** — Auto, HTTP-only, SOCKS5-only, Random Mix
|
- **Obfuscation modes** — Auto, HTTP-only, SOCKS5-only, Random Mix
|
||||||
- **Manual chain builder** — drag/reorder hops, pin your own IPs
|
- **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
|
- **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
|
- **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
|
- **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
|
## Chain Builder Tab
|
||||||
|
|
||||||
- **Obfuscation mode** — selects which protocols to include in chains
|
- **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
|
- **Manual chain** — enter proxies manually, reorder with ↑↓, enable "Use this chain" to pin it
|
||||||
- **Paste current** — copies the auto-selected chain into the editor
|
- **Paste current** — copies the auto-selected chain into the editor
|
||||||
- **Sources** — add/remove proxy list URLs (JSON format from Proxifly)
|
- **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,
|
OBFUSCATION_MODES,
|
||||||
Settings,
|
Settings,
|
||||||
load_settings,
|
load_settings,
|
||||||
|
normalize_proxy_url,
|
||||||
save_settings,
|
save_settings,
|
||||||
)
|
)
|
||||||
from .firewall import (
|
from .firewall import (
|
||||||
@@ -176,6 +177,7 @@ def main() -> None:
|
|||||||
obfuscation_mode=cur.obfuscation_mode,
|
obfuscation_mode=cur.obfuscation_mode,
|
||||||
use_pinned_chain=cur.use_pinned_chain,
|
use_pinned_chain=cur.use_pinned_chain,
|
||||||
pinned_chain=cur.pinned_chain,
|
pinned_chain=cur.pinned_chain,
|
||||||
|
manual_exit_proxy=cur.manual_exit_proxy,
|
||||||
health_check_seconds=cur.health_check_seconds,
|
health_check_seconds=cur.health_check_seconds,
|
||||||
full_refresh_seconds=cur.full_refresh_seconds,
|
full_refresh_seconds=cur.full_refresh_seconds,
|
||||||
validation_concurrency=cur.validation_concurrency,
|
validation_concurrency=cur.validation_concurrency,
|
||||||
@@ -273,7 +275,9 @@ def main() -> None:
|
|||||||
d.configure(text_color=c)
|
d.configure(text_color=c)
|
||||||
pulse_job["id"] = root.after(380, lambda s=step: _pulse(s + 1)) # type: ignore[arg-type]
|
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()
|
_cancel_pulse()
|
||||||
for w in list(chain_canvas.winfo_children()):
|
for w in list(chain_canvas.winfo_children()):
|
||||||
w.destroy()
|
w.destroy()
|
||||||
@@ -296,11 +300,15 @@ def main() -> None:
|
|||||||
_node("NORD", PURPLE, bold=True)
|
_node("NORD", PURPLE, bold=True)
|
||||||
_arr()
|
_arr()
|
||||||
|
|
||||||
|
last_i = len(hops) - 1
|
||||||
for i, h in enumerate(hops):
|
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)
|
dot.pack(side="left", padx=1)
|
||||||
hop_dot_labels.append(dot)
|
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:
|
if i < len(hops) - 1:
|
||||||
_arr()
|
_arr()
|
||||||
|
|
||||||
@@ -448,7 +456,35 @@ def main() -> None:
|
|||||||
cb_left, text="Elite proxies only (slower but more anonymous)",
|
cb_left, text="Elite proxies only (slower but more anonymous)",
|
||||||
variable=elite_var, font=(FONT, 11),
|
variable=elite_var, font=(FONT, 11),
|
||||||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
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
|
# Right — manual chain editor
|
||||||
cb_right = ctk.CTkFrame(cb_split, fg_color=CARD, corner_radius=8, width=400)
|
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(),
|
obfuscation_mode=mode_var.get(),
|
||||||
use_pinned_chain=bool(use_manual_var.get()),
|
use_pinned_chain=bool(use_manual_var.get()),
|
||||||
pinned_chain=list(manual_chain),
|
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())),
|
health_check_seconds=max(10, int(entries["health"].get().strip())),
|
||||||
full_refresh_seconds=max(60, int(entries["refresh"].get().strip())),
|
full_refresh_seconds=max(60, int(entries["refresh"].get().strip())),
|
||||||
validation_concurrency=max(1, int(entries["conc"].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 [])]
|
hops = [str(x) for x in (m.get("hops") or [])]
|
||||||
status = str(m.get("status", "connecting"))
|
status = str(m.get("status", "connecting"))
|
||||||
exit_ip = m.get("exit_ip")
|
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()
|
_refresh_sysproxy()
|
||||||
tray.set_state(
|
tray.set_state(
|
||||||
{"healthy": "green", "dead": "red", "connecting": "yellow"}.get(status, "yellow")
|
{"healthy": "green", "dead": "red", "connecting": "yellow"}.get(status, "yellow")
|
||||||
@@ -792,6 +831,7 @@ def main() -> None:
|
|||||||
_log(f"Log file: {LOG_PATH}")
|
_log(f"Log file: {LOG_PATH}")
|
||||||
_log("Press START to fetch, validate and chain proxies.")
|
_log("Press START to fetch, validate and chain proxies.")
|
||||||
_log("Use +/− in top bar to change hop count instantly.")
|
_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)
|
_log("─" * 60)
|
||||||
_pump()
|
_pump()
|
||||||
root.mainloop()
|
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.
|
# do not affect 127.0.0.1; avoid storing LAN IPs or hostnames for the listener.
|
||||||
LISTEN_HOST = "127.0.0.1"
|
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_MODES = ["auto", "http_only", "socks5_only", "random_mix"]
|
||||||
OBFUSCATION_LABELS = {
|
OBFUSCATION_LABELS = {
|
||||||
"auto": "Auto (best available)",
|
"auto": "Auto (best available)",
|
||||||
@@ -30,6 +41,8 @@ class Settings:
|
|||||||
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
||||||
use_pinned_chain: bool = False # use manually ordered chain
|
use_pinned_chain: bool = False # use manually ordered chain
|
||||||
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
|
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 ────────────────────────────────────────────────────────────
|
# ── timing ────────────────────────────────────────────────────────────
|
||||||
health_check_seconds: int = 180 # how often to re-verify exit IP
|
health_check_seconds: int = 180 # how often to re-verify exit IP
|
||||||
@@ -88,6 +101,8 @@ def load_settings() -> Settings:
|
|||||||
s.obfuscation_mode = "auto"
|
s.obfuscation_mode = "auto"
|
||||||
if not isinstance(s.pinned_chain, list):
|
if not isinstance(s.pinned_chain, list):
|
||||||
s.pinned_chain = []
|
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)
|
s, changed = normalize_listen_host(s)
|
||||||
if changed:
|
if changed:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from collections.abc import Callable
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any
|
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 .fetcher import fetch_proxy_json, normalize_entries
|
||||||
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin
|
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
|
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
|
# Per-session blacklist: proxies that crashed GOST immediately
|
||||||
self._blacklist: set[str] = set()
|
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
|
@property
|
||||||
def settings(self) -> Settings:
|
def settings(self) -> Settings:
|
||||||
return self._settings
|
return self._settings
|
||||||
@@ -176,6 +180,18 @@ class ChainService:
|
|||||||
break
|
break
|
||||||
continue
|
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 ─────────────────────────────────────────────────
|
# ── Pool refresh ─────────────────────────────────────────────────
|
||||||
if need_refresh:
|
if need_refresh:
|
||||||
self._notify({"type": "phase", "phase": "fetch"})
|
self._notify({"type": "phase", "phase": "fetch"})
|
||||||
@@ -240,8 +256,11 @@ class ChainService:
|
|||||||
await asyncio.sleep(2.0)
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
if self._proc.poll() is not None:
|
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:
|
for h in chain:
|
||||||
|
if fixed and h == fixed:
|
||||||
|
continue
|
||||||
self._blacklist.add(h)
|
self._blacklist.add(h)
|
||||||
self._available = [x for x in self._available if x != h]
|
self._available = [x for x in self._available if x != h]
|
||||||
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
|
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
|
||||||
@@ -305,17 +324,38 @@ class ChainService:
|
|||||||
# ─────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _pick_chain(self) -> list[str]:
|
def _pick_chain(self) -> list[str]:
|
||||||
"""Pick chain_length proxies from _available (shuffle-and-drain).
|
"""Pick chain_length hops: optional fixed last hop + random prefix from pool."""
|
||||||
Filters out blacklisted proxies. Marks picked ones as used."""
|
|
||||||
s = self._settings
|
s = self._settings
|
||||||
k = max(1, s.chain_length)
|
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
|
if mid_need == 0:
|
||||||
candidates = [
|
return [manual]
|
||||||
u for u in self._available
|
if len(candidates) < mid_need:
|
||||||
if u not in self._blacklist
|
return []
|
||||||
]
|
|
||||||
# Apply obfuscation mode filter
|
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":
|
if s.obfuscation_mode == "http_only":
|
||||||
candidates = [u for u in candidates if u.startswith("http://")]
|
candidates = [u for u in candidates if u.startswith("http://")]
|
||||||
elif s.obfuscation_mode == "socks5_only":
|
elif s.obfuscation_mode == "socks5_only":
|
||||||
@@ -327,7 +367,6 @@ class ChainService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
picked = candidates[:k]
|
picked = candidates[:k]
|
||||||
# Remove picked from available
|
|
||||||
picked_set = set(picked)
|
picked_set = set(picked)
|
||||||
self._available = [u for u in self._available if u not in picked_set]
|
self._available = [u for u in self._available if u not in picked_set]
|
||||||
self._used.update(picked)
|
self._used.update(picked)
|
||||||
@@ -386,6 +425,24 @@ class ChainService:
|
|||||||
on_progress=on_prog,
|
on_progress=on_prog,
|
||||||
)
|
)
|
||||||
random.shuffle(good)
|
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": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"})
|
||||||
self._notify({"type": "phase", "phase": "running"})
|
self._notify({"type": "phase", "phase": "running"})
|
||||||
return good
|
return good
|
||||||
|
|||||||
Reference in New Issue
Block a user