optimize: shuffle-drain pool (no repeat chains), executor for fetchers, DEVNULL pipes, verify=False validator, IP fallbacks, +/- hop topbar, timestamps, countdown, rotation counter, blacklist bad proxies

Made-with: Cursor
This commit is contained in:
Dr Jones
2026-04-14 17:21:10 -07:00
parent 4bb626498f
commit 45798464e4
16 changed files with 8208 additions and 7935 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -6903,6 +6903,7 @@ imported by:
&#8226; <a href="#concurrent.futures._base">concurrent.futures._base</a>
&#8226; <a href="#concurrent.futures.process">concurrent.futures.process</a>
&#8226; <a href="#concurrent.futures.thread">concurrent.futures.thread</a>
&#8226; <a href="#proxy_chain_manager.service">proxy_chain_manager.service</a>
</div>
@@ -8630,6 +8631,7 @@ imported by:
&#8226; <a href="#http.cookiejar">http.cookiejar</a>
&#8226; <a href="#httpx._client">httpx._client</a>
&#8226; <a href="#httpx._models">httpx._models</a>
&#8226; <a href="#proxy_chain_manager.app">proxy_chain_manager.app</a>
&#8226; <a href="#xmlrpc.client">xmlrpc.client</a>
</div>
@@ -16776,6 +16778,7 @@ imported by:
imports:
<a href="#__future__">__future__</a>
&#8226; <a href="#customtkinter">customtkinter</a>
&#8226; <a href="#datetime">datetime</a>
&#8226; <a href="#logging">logging</a>
&#8226; <a href="#proxy_chain_manager">proxy_chain_manager</a>
&#8226; <a href="#proxy_chain_manager.config">proxy_chain_manager.config</a>
@@ -16921,6 +16924,7 @@ imports:
<a href="#__future__">__future__</a>
&#8226; <a href="#asyncio">asyncio</a>
&#8226; <a href="#collections.abc">collections.abc</a>
&#8226; <a href="#concurrent.futures">concurrent.futures</a>
&#8226; <a href="#logging">logging</a>
&#8226; <a href="#proxy_chain_manager">proxy_chain_manager</a>
&#8226; <a href="#proxy_chain_manager.config">proxy_chain_manager.config</a>

Binary file not shown.

View File

@@ -1,5 +1,5 @@
"""
Proxy Chain Manager — GUI
Proxy God — GUI
Tabs: Live | Chain Builder | Settings
"""
from __future__ import annotations
@@ -7,6 +7,7 @@ from __future__ import annotations
import logging
import queue
import sys
from datetime import datetime
from typing import Any
import customtkinter as ctk
@@ -19,7 +20,12 @@ from .config import (
load_settings,
save_settings,
)
from .firewall import disengage as fw_disengage, is_admin, is_engaged as fw_is_engaged, request_admin_relaunch
from .firewall import (
disengage as fw_disengage,
is_admin,
is_engaged as fw_is_engaged,
request_admin_relaunch,
)
from .paths import app_data_dir
from .service import ChainService
from .sysproxy import clear_system_proxy, is_system_proxy_set
@@ -45,6 +51,7 @@ ACCENT2 = "#2563eb"
GREEN = "#00e676"
RED = "#ff1744"
YELLOW = "#ffab00"
ORANGE = "#ff6d00"
PURPLE = "#bb86fc"
BLUE = "#448aff"
CYAN = "#00bcd4"
@@ -54,14 +61,18 @@ TEXT2 = "#94a3b8"
FONT = "Segoe UI"
def _ts() -> str:
return datetime.now().strftime("%H:%M:%S")
def main() -> None:
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("dark-blue")
root = ctk.CTk()
root.title("Proxy God v2")
root.geometry("1060x720")
root.minsize(900, 600)
root.geometry("1080x740")
root.minsize(900, 620)
root.configure(fg_color=BG)
ui_q: queue.Queue[dict[str, Any]] = queue.Queue()
@@ -69,126 +80,187 @@ def main() -> None:
s = load_settings()
# ── tray ─────────────────────────────────────────────────────────────────
def _tray_show() -> None:
root.after(0, lambda: (root.deiconify(), root.lift()))
def _tray_quit() -> None:
root.after(0, _close)
tray = TrayIcon(on_show=_tray_show, on_quit=_tray_quit, on_rotate=svc.rotate_now)
tray = TrayIcon(
on_show=lambda: root.after(0, lambda: (root.deiconify(), root.lift())),
on_quit=lambda: root.after(0, _close),
on_rotate=svc.rotate_now,
)
tray.start()
# ─────────────────────────────────────────────────────────────────────────
# SHARED STATE
# ─────────────────────────────────────────────────────────────────────────
_hop_count = [s.chain_length] # mutable ref so topbar buttons can mutate it
pulse_job: dict[str, str | None] = {"id": None}
def _cancel_pulse() -> None:
if pulse_job["id"]:
try:
root.after_cancel(pulse_job["id"]) # type: ignore[arg-type]
except Exception:
pass
pulse_job["id"] = None
def _short(u: str) -> str:
u = u.replace("http://", "").replace("socks5://", "s5://").replace("socks4://", "s4://").replace("https://", "")
return u[:28] + "" if len(u) > 30 else u
# ─────────────────────────────────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────────────────────────────────
def _btn(parent: Any, label: str, cmd: Any, w: int = 80, h: int = 28, **kw: Any) -> ctk.CTkButton:
cfg = {"fg_color": ACCENT, "hover_color": ACCENT2, "text_color": TEXT}
cfg: dict[str, Any] = {"fg_color": ACCENT, "hover_color": ACCENT2, "text_color": TEXT}
cfg.update(kw)
return ctk.CTkButton(
parent, text=label, command=cmd, width=w, height=h,
font=(FONT, 11), corner_radius=6, **cfg,
)
pulse_job: dict[str, Any] = {"id": None}
def _cancel_pulse() -> None:
jid = pulse_job.get("id")
if jid:
try:
root.after_cancel(jid)
except Exception:
pass
pulse_job["id"] = None
def _short(u: str) -> str:
u = u.replace("http://", "").replace("socks5://", "s5://").replace("socks4://", "s4://")
return u[:28] + "" if len(u) > 30 else u
# ─────────────────────────────────────────────────────────────────────────
# TOP BAR
# ─────────────────────────────────────────────────────────────────────────
topbar = ctk.CTkFrame(root, fg_color=CARD, corner_radius=0, height=46)
topbar = ctk.CTkFrame(root, fg_color=CARD, corner_radius=0, height=48)
topbar.pack(fill="x", side="top")
topbar.pack_propagate(False)
# Status
status_dot = ctk.CTkLabel(topbar, text="", font=(FONT, 20), text_color=RED)
status_dot.pack(side="left", padx=(10, 2))
status_txt = ctk.CTkLabel(topbar, text="STOPPED", font=(FONT, 12, "bold"), text_color=TEXT)
status_txt.pack(side="left", padx=(0, 10))
status_txt.pack(side="left", padx=(0, 8))
def _start() -> None:
_save_settings()
svc.start()
_btn(topbar, " Start", _start, w=90).pack(side="left", padx=2)
_btn(topbar, " Stop", svc.stop, w=80).pack(side="left", padx=2)
_btn(topbar, " Rotate", svc.rotate_now, w=80).pack(side="left", padx=2)
_btn(topbar, "Quit", lambda: root.after(0, _close), w=55,
fg_color="#7f1d1d", hover_color="#991b1b").pack(side="left", padx=(8, 2))
_btn(topbar, "▶ Start", _start, w=86).pack(side="left", padx=2)
_btn(topbar, "■ Stop", svc.stop, w=76).pack(side="left", padx=2)
_btn(topbar, "↻ Rotate", svc.rotate_now, w=76).pack(side="left", padx=2)
_btn(topbar, "Quit", lambda: root.after(0, _close), w=52,
fg_color="#7f1d1d", hover_color="#991b1b").pack(side="left", padx=(10, 2))
# Hop count +/- control
ctk.CTkLabel(topbar, text="", text_color=DIM).pack(side="left", padx=6)
ctk.CTkLabel(topbar, text="Hops:", font=(FONT, 11), text_color=TEXT2).pack(side="left", padx=(0, 2))
hop_count_lbl = ctk.CTkLabel(topbar, text=str(_hop_count[0]),
font=(FONT, 15, "bold"), text_color=CYAN, width=24)
hop_count_lbl.pack(side="left")
def _hop_adjust(delta: int) -> None:
new = max(1, min(8, _hop_count[0] + delta))
_hop_count[0] = new
hop_count_lbl.configure(text=str(new))
# Update hop slider in Chain Builder if it exists
try:
hop_slider.set(new)
hop_val_lbl.configure(text=str(new))
except Exception:
pass
# Immediately apply to settings
_apply_hop_count(new)
_btn(topbar, "", lambda: _hop_adjust(-1), w=26, h=26,
fg_color=PANEL, hover_color=ACCENT).pack(side="left", padx=1)
_btn(topbar, "+", lambda: _hop_adjust(+1), w=26, h=26,
fg_color=PANEL, hover_color=ACCENT).pack(side="left", padx=(1, 6))
def _apply_hop_count(n: int) -> None:
"""Save hop count change to live settings immediately."""
cur = svc.settings
ns = Settings(
local_host=cur.local_host,
local_port=cur.local_port,
chain_length=n,
obfuscation_mode=cur.obfuscation_mode,
use_pinned_chain=cur.use_pinned_chain,
pinned_chain=cur.pinned_chain,
health_check_seconds=cur.health_check_seconds,
full_refresh_seconds=cur.full_refresh_seconds,
validation_concurrency=cur.validation_concurrency,
max_candidates=cur.max_candidates,
validation_timeout_seconds=cur.validation_timeout_seconds,
prefer_elite=cur.prefer_elite,
kill_switch_enabled=cur.kill_switch_enabled,
proxy_bypass=cur.proxy_bypass,
sources=cur.sources,
ip_check_url=cur.ip_check_url,
)
svc.update_settings(ns)
# Boot buttons
ctk.CTkLabel(topbar, text="", text_color=DIM).pack(side="left", padx=4)
boot_lbl = ctk.CTkLabel(topbar, text="Boot:?", font=(FONT, 10), text_color=TEXT2)
boot_lbl.pack(side="left", padx=2)
def _refresh_boot() -> None:
on = task_exists()
boot_lbl.configure(text="Boot: ON" if on else "Boot: OFF",
boot_lbl.configure(text="Boot:ON" if on else "Boot:OFF",
text_color=GREEN if on else DIM)
def _inst_boot() -> None:
ok, msg = install_logon_task()
_log("Boot task installed." if ok else f"Boot install fail: {msg}")
_log("Boot task installed." if ok else f"Boot install: {msg}")
_refresh_boot()
def _rm_boot() -> None:
ok, msg = uninstall_logon_task()
_log("Boot task removed." if ok else f"Boot remove fail: {msg}")
_log("Boot task removed." if ok else f"Boot remove: {msg}")
_refresh_boot()
_btn(topbar, "Boot+", _inst_boot, w=55).pack(side="left", padx=2)
_btn(topbar, "Boot-", _rm_boot, w=55).pack(side="left", padx=2)
_btn(topbar, "Boot+", _inst_boot, w=54).pack(side="left", padx=2)
_btn(topbar, "Boot", _rm_boot, w=54).pack(side="left", padx=2)
# Right side status indicators
proxy_lbl = ctk.CTkLabel(topbar, text=f"proxy: {LISTEN_HOST}:{s.local_port}",
# Right side indicators
rotation_lbl = ctk.CTkLabel(topbar, text="rot: 0", font=(FONT, 10), text_color=TEXT2)
rotation_lbl.pack(side="right", padx=(4, 12))
countdown_lbl = ctk.CTkLabel(topbar, text="next: —", font=(FONT, 10), text_color=TEXT2)
countdown_lbl.pack(side="right", padx=4)
proxy_lbl = ctk.CTkLabel(topbar, text=f"{LISTEN_HOST}:{s.local_port}",
font=(FONT, 11, "bold"), text_color=BLUE)
proxy_lbl.pack(side="right", padx=(4, 12))
proxy_lbl.pack(side="right", padx=(4, 4))
ctk.CTkLabel(topbar, text="proxy:", font=(FONT, 10), text_color=TEXT2).pack(side="right")
fw_lbl = ctk.CTkLabel(topbar, text="FW:—", font=(FONT, 10), text_color=DIM)
fw_lbl.pack(side="right", padx=4)
fw_lbl.pack(side="right", padx=6)
sys_lbl = ctk.CTkLabel(topbar, text="SYS:—", font=(FONT, 10), text_color=DIM)
sys_lbl.pack(side="right", padx=4)
admin_lbl = ctk.CTkLabel(topbar,
text="⚡ADMIN" if is_admin() else "👤USER",
font=(FONT, 10, "bold"),
text_color=GREEN if is_admin() else YELLOW)
admin_lbl.pack(side="right", padx=4)
admin_badge = ctk.CTkLabel(
topbar,
text="⚡ ADMIN" if is_admin() else "👤 USER",
font=(FONT, 10, "bold"),
text_color=GREEN if is_admin() else YELLOW,
)
admin_badge.pack(side="right", padx=4)
if not is_admin():
def _elevate() -> None:
if request_admin_relaunch():
_close()
_btn(topbar, "Run as Admin", _elevate, w=100,
_btn(topbar, "Run as Admin", _elevate, w=104,
fg_color="#7f1d1d", hover_color="#991b1b").pack(side="right", padx=4)
# ─────────────────────────────────────────────────────────────────────────
# CHAIN VIZ BAR
# ─────────────────────────────────────────────────────────────────────────
viz_bar = ctk.CTkFrame(root, fg_color=PANEL, corner_radius=0, height=54)
viz_bar = ctk.CTkFrame(root, fg_color=PANEL, corner_radius=0, height=52)
viz_bar.pack(fill="x")
viz_bar.pack_propagate(False)
your_ip_lbl = ctk.CTkLabel(viz_bar, text="Your IP: —", font=(FONT, 10), text_color=TEXT2)
your_ip_lbl.pack(side="left", padx=(12, 6), pady=4)
your_ip_lbl.pack(side="left", padx=(12, 8))
chain_canvas = ctk.CTkFrame(viz_bar, fg_color="transparent")
chain_canvas.pack(side="left", fill="both", expand=True, padx=4)
chain_canvas.pack(side="left", fill="both", expand=True)
exit_ip_lbl = ctk.CTkLabel(viz_bar, text="Exit: —",
font=(FONT, 13, "bold"), text_color=YELLOW)
exit_ip_lbl.pack(side="right", padx=(6, 14))
exit_ip_lbl.pack(side="right", padx=(8, 14))
hop_dot_labels: list[ctk.CTkLabel] = []
@@ -199,7 +271,7 @@ def main() -> None:
c = YELLOW if step % 2 == 0 else DIM
for d in hop_dot_labels:
d.configure(text_color=c)
pulse_job["id"] = root.after(380, lambda: _pulse(step + 1))
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:
_cancel_pulse()
@@ -207,12 +279,14 @@ def main() -> None:
w.destroy()
hop_dot_labels.clear()
color_map = {"healthy": GREEN, "dead": RED, "connecting": YELLOW}
hop_c = color_map.get(status, DIM)
hop_c = {"healthy": GREEN, "dead": RED, "connecting": YELLOW}.get(status, DIM)
def _node(text: str, color: str, bold: bool = False) -> None:
f = (FONT, 11, "bold") if bold else (FONT, 10)
ctk.CTkLabel(chain_canvas, text=text, font=f, text_color=color).pack(side="left", padx=1)
ctk.CTkLabel(
chain_canvas, text=text,
font=(FONT, 11, "bold") if bold else (FONT, 10),
text_color=color,
).pack(side="left", padx=1)
def _arr() -> None:
ctk.CTkLabel(chain_canvas, text="", font=(FONT, 11), text_color=DIM).pack(side="left", padx=2)
@@ -233,14 +307,10 @@ def main() -> None:
_arr()
_node("WEB", BLUE, bold=True)
if exit_ip:
exit_ip_lbl.configure(
text=f"Exit: {exit_ip}",
text_color=GREEN if status == "healthy" else RED,
)
else:
exit_ip_lbl.configure(text="Exit: —",
text_color=YELLOW if status == "connecting" else DIM)
exit_ip_lbl.configure(
text=f"Exit: {exit_ip}" if exit_ip else "Exit: —",
text_color=GREEN if status == "healthy" else (RED if exit_ip else YELLOW),
)
if status == "connecting" and hop_dot_labels:
_pulse()
@@ -250,153 +320,175 @@ def main() -> None:
# ─────────────────────────────────────────────────────────────────────────
prog_strip = ctk.CTkFrame(root, fg_color=BG, height=22)
prog_strip.pack(fill="x", padx=8, pady=(4, 0))
phase_lbl = ctk.CTkLabel(prog_strip, text="idle", font=(FONT, 10), text_color=TEXT2)
prog_strip.pack_propagate(False)
phase_lbl = ctk.CTkLabel(prog_strip, text="idle", font=(FONT, 10), text_color=TEXT2, width=70)
phase_lbl.pack(side="left", padx=4)
prog_bar = ctk.CTkProgressBar(prog_strip, height=8, corner_radius=4,
fg_color=CARD, progress_color=BLUE)
prog_bar.pack(side="left", fill="x", expand=True, padx=4)
prog_bar.set(0)
pool_lbl = ctk.CTkLabel(prog_strip, text="pool: 0", font=(FONT, 10), text_color=TEXT2)
pool_lbl = ctk.CTkLabel(prog_strip, text="pool: 0", font=(FONT, 10), text_color=TEXT2, width=70)
pool_lbl.pack(side="right", padx=4)
# ─────────────────────────────────────────────────────────────────────────
# TABVIEW
# ─────────────────────────────────────────────────────────────────────────
tabs = ctk.CTkTabview(root, fg_color=BG, segmented_button_fg_color=CARD,
segmented_button_selected_color=ACCENT2,
segmented_button_unselected_color=CARD,
segmented_button_selected_hover_color=ACCENT,
border_width=0)
tabs = ctk.CTkTabview(
root,
fg_color=BG,
segmented_button_fg_color=CARD,
segmented_button_selected_color=ACCENT2,
segmented_button_unselected_color=CARD,
segmented_button_selected_hover_color=ACCENT,
border_width=0,
)
tabs.pack(fill="both", expand=True, padx=8, pady=(4, 8))
tab_live = tabs.add(" Live ")
tab_chain = tabs.add(" Chain Builder ")
tab_settings = tabs.add(" Settings ")
for tab in (tab_live, tab_chain, tab_settings):
tab.configure(fg_color=BG)
# ─── TAB: LIVE ───────────────────────────────────────────────────────────
tab_live.configure(fg_color=BG)
# Stats row
# Stats cards row
stats_row = ctk.CTkFrame(tab_live, fg_color="transparent")
stats_row.pack(fill="x", pady=(4, 6))
def _stat_card(parent: Any, title: str, var: ctk.StringVar) -> None:
def _stat_card(parent: Any, title: str, var: ctk.StringVar, color: str = TEXT) -> None:
f = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=8)
f.pack(side="left", fill="x", expand=True, padx=4)
ctk.CTkLabel(f, text=title, font=(FONT, 9), text_color=TEXT2).pack(anchor="w", padx=8, pady=(6, 0))
ctk.CTkLabel(f, textvariable=var, font=(FONT, 15, "bold"), text_color=TEXT).pack(anchor="w", padx=8, pady=(0, 6))
ctk.CTkLabel(f, text=title, font=(FONT, 9), text_color=TEXT2).pack(
anchor="w", padx=8, pady=(6, 0))
ctk.CTkLabel(f, textvariable=var, font=(FONT, 14, "bold"), text_color=color).pack(
anchor="w", padx=8, pady=(0, 6))
v_real_ip = ctk.StringVar(value="")
v_exit_ip = ctk.StringVar(value="")
v_pool = ctk.StringVar(value="0")
v_hops = ctk.StringVar(value="")
_stat_card(stats_row, "Your IP (Nord/ISP)", v_real_ip)
_stat_card(stats_row, "Exit IP (last proxy)", v_exit_ip)
_stat_card(stats_row, "Valid proxy pool", v_pool)
_stat_card(stats_row, "Active hops", v_hops)
v_rotation = ctk.StringVar(value="0")
_stat_card(stats_row, "Your IP (through Nord)", v_real_ip)
_stat_card(stats_row, "Exit IP (last hop)", v_exit_ip)
_stat_card(stats_row, "Valid pool size", v_pool)
_stat_card(stats_row, "Rotations this session", v_rotation)
# Log box
log_frame = ctk.CTkFrame(tab_live, fg_color=CARD, corner_radius=8)
log_frame.pack(fill="both", expand=True, padx=0)
log_box = ctk.CTkTextbox(log_frame, font=("Consolas", 11), fg_color=BG,
text_color=TEXT, scrollbar_button_color=ACCENT)
log_frame.pack(fill="both", expand=True)
log_box = ctk.CTkTextbox(
log_frame, font=("Consolas", 11),
fg_color=BG, text_color=TEXT,
scrollbar_button_color=ACCENT,
)
log_box.pack(fill="both", expand=True, padx=4, pady=4)
def _log(line: str) -> None:
log_box.insert("end", line + "\n")
log_box.insert("end", f"[{_ts()}] {line}\n")
log_box.see("end")
# ─── TAB: CHAIN BUILDER ──────────────────────────────────────────────────
tab_chain.configure(fg_color=BG)
cb_split = ctk.CTkFrame(tab_chain, fg_color="transparent")
cb_split.pack(fill="x", pady=(4, 8))
cb_top = ctk.CTkFrame(tab_chain, fg_color="transparent")
cb_top.pack(fill="x", pady=(4, 8))
# Left col — mode + hops
cb_left = ctk.CTkFrame(cb_top, fg_color=CARD, corner_radius=8)
# Left — mode + hop slider
cb_left = ctk.CTkFrame(cb_split, fg_color=CARD, corner_radius=8)
cb_left.pack(side="left", fill="both", expand=True, padx=(0, 6))
ctk.CTkLabel(cb_left, text="Obfuscation Mode", font=(FONT, 12, "bold"), text_color=TEXT).pack(
anchor="w", padx=10, pady=(10, 4))
ctk.CTkLabel(cb_left, text="Obfuscation Mode", font=(FONT, 12, "bold"),
text_color=TEXT).pack(anchor="w", padx=12, pady=(12, 4))
mode_var = ctk.StringVar(value=s.obfuscation_mode)
for mode_key in OBFUSCATION_MODES:
for mk in OBFUSCATION_MODES:
ctk.CTkRadioButton(
cb_left,
text=OBFUSCATION_LABELS[mode_key],
text=OBFUSCATION_LABELS[mk],
variable=mode_var,
value=mode_key,
value=mk,
font=(FONT, 11),
fg_color=ACCENT2,
hover_color=ACCENT,
text_color=TEXT,
).pack(anchor="w", padx=14, pady=2)
).pack(anchor="w", padx=16, pady=3)
ctk.CTkLabel(cb_left, text="", height=6).pack()
ctk.CTkLabel(cb_left, text="Hop Count", font=(FONT, 12, "bold"), text_color=TEXT).pack(
anchor="w", padx=10)
ctk.CTkFrame(cb_left, fg_color="transparent", height=8).pack()
ctk.CTkLabel(cb_left, text="Hop Count (also: topbar +/)",
font=(FONT, 12, "bold"), text_color=TEXT).pack(anchor="w", padx=12)
hop_val_lbl = ctk.CTkLabel(cb_left, text=str(s.chain_length),
font=(FONT, 22, "bold"), text_color=CYAN)
hop_val_lbl = ctk.CTkLabel(cb_left, text=str(_hop_count[0]),
font=(FONT, 26, "bold"), text_color=CYAN)
hop_val_lbl.pack(pady=(2, 0))
def _on_hop_slider(val: float) -> None:
hop_val_lbl.configure(text=str(int(val)))
n = int(val)
_hop_count[0] = n
hop_val_lbl.configure(text=str(n))
hop_count_lbl.configure(text=str(n))
_apply_hop_count(n)
hop_slider = ctk.CTkSlider(cb_left, from_=2, to=8, number_of_steps=6,
command=_on_hop_slider,
fg_color=CARD, progress_color=ACCENT2,
button_color=CYAN, button_hover_color=BLUE)
hop_slider = ctk.CTkSlider(
cb_left, from_=1, to=8, number_of_steps=7,
command=_on_hop_slider,
fg_color=PANEL, progress_color=ACCENT2,
button_color=CYAN, button_hover_color=BLUE,
)
hop_slider.set(s.chain_length)
hop_slider.pack(fill="x", padx=14, pady=(2, 12))
hop_slider.pack(fill="x", padx=16, pady=(2, 6))
elite_var2 = ctk.BooleanVar(value=s.prefer_elite)
ctk.CTkCheckBox(cb_left, text="Elite proxies only",
variable=elite_var2, font=(FONT, 11),
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT).pack(
anchor="w", padx=14, pady=(0, 10))
hop_legend = ctk.CTkFrame(cb_left, fg_color="transparent")
hop_legend.pack(fill="x", padx=16, pady=(0, 8))
ctk.CTkLabel(hop_legend, text="1", font=(FONT, 9), text_color=DIM).pack(side="left")
ctk.CTkLabel(hop_legend, text="8", font=(FONT, 9), text_color=DIM).pack(side="right")
# Right col — manual chain editor
cb_right = ctk.CTkFrame(cb_top, fg_color=CARD, corner_radius=8, width=380)
elite_var = ctk.BooleanVar(value=s.prefer_elite)
ctk.CTkCheckBox(
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))
# Right — manual chain editor
cb_right = ctk.CTkFrame(cb_split, fg_color=CARD, corner_radius=8, width=400)
cb_right.pack(side="left", fill="both", expand=True)
hdr_row = ctk.CTkFrame(cb_right, fg_color="transparent")
hdr_row.pack(fill="x", padx=10, pady=(10, 4))
ctk.CTkLabel(hdr_row, text="Manual Chain", font=(FONT, 12, "bold"), text_color=TEXT).pack(side="left")
hdr_row.pack(fill="x", padx=12, pady=(12, 4))
ctk.CTkLabel(hdr_row, text="Manual Chain",
font=(FONT, 12, "bold"), text_color=TEXT).pack(side="left")
use_manual_var = ctk.BooleanVar(value=s.use_pinned_chain)
ctk.CTkCheckBox(hdr_row, text="Use this chain",
variable=use_manual_var,
font=(FONT, 10), fg_color=ACCENT2,
hover_color=ACCENT, text_color=TEXT2).pack(side="right")
# Listbox-style chain using a scrollable frame with rows
chain_list_frame = ctk.CTkScrollableFrame(cb_right, fg_color=BG,
height=160, corner_radius=6,
scrollbar_button_color=ACCENT)
chain_list_frame.pack(fill="x", padx=10, pady=(0, 6))
ctk.CTkCheckBox(
hdr_row, text="Pin & use this chain",
variable=use_manual_var, font=(FONT, 10),
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT2,
).pack(side="right")
manual_chain: list[str] = list(s.pinned_chain)
row_frames: list[ctk.CTkFrame] = []
chain_list_frame = ctk.CTkScrollableFrame(
cb_right, fg_color=BG, height=170, corner_radius=6,
scrollbar_button_color=ACCENT,
)
chain_list_frame.pack(fill="x", padx=12, pady=(0, 6))
def _rebuild_chain_ui() -> None:
for w in list(chain_list_frame.winfo_children()):
w.destroy()
row_frames.clear()
for i, hop in enumerate(manual_chain):
_chain_row(i, hop)
def _chain_row(idx: int, hop: str) -> None:
fr = ctk.CTkFrame(chain_list_frame, fg_color=PANEL, corner_radius=6, height=30)
fr = ctk.CTkFrame(chain_list_frame, fg_color=PANEL, corner_radius=6, height=32)
fr.pack(fill="x", pady=2)
fr.pack_propagate(False)
row_frames.append(fr)
# Drag indicator + index
ctk.CTkLabel(fr, text=f" {idx+1}.", font=(FONT, 10, "bold"),
text_color=CYAN, width=24).pack(side="left")
text_color=CYAN, width=26).pack(side="left")
# Protocol badge
proto = hop.split("://")[0].upper() if "://" in hop else "?"
proto = hop.split("://")[0].upper() if "://" in hop else "HTTP"
badge_c = {"HTTP": "#1e3a8a", "SOCKS5": "#064e3b", "SOCKS4": "#3b1a64"}.get(proto, DIM)
ctk.CTkLabel(fr, text=proto, font=(FONT, 9, "bold"),
fg_color=badge_c, corner_radius=4, padx=4,
@@ -419,67 +511,64 @@ def main() -> None:
del manual_chain[i]
_rebuild_chain_ui()
_btn(fr, "", _up, w=24, h=22, fg_color=DIM, hover_color=ACCENT).pack(side="right", padx=1)
_btn(fr, "", _dn, w=24, h=22, fg_color=DIM, hover_color=ACCENT).pack(side="right", padx=1)
_btn(fr, "", _rm, w=24, h=22, fg_color="#7f1d1d", hover_color=RED).pack(side="right", padx=(1, 4))
_btn(fr, "", _up, w=26, h=24, fg_color=DIM, hover_color=ACCENT).pack(side="right", padx=1)
_btn(fr, "", _dn, w=26, h=24, fg_color=DIM, hover_color=ACCENT).pack(side="right", padx=1)
_btn(fr, "", _rm, w=26, h=24, fg_color="#7f1d1d", hover_color=RED).pack(side="right", padx=(1, 6))
_rebuild_chain_ui()
# Add proxy row
# Add proxy input
add_row = ctk.CTkFrame(cb_right, fg_color="transparent")
add_row.pack(fill="x", padx=10, pady=(0, 4))
add_entry = ctk.CTkEntry(add_row, placeholder_text="http://1.2.3.4:8080 or socks5://...",
font=(FONT, 10), fg_color=BG, border_color=ACCENT, height=28)
add_row.pack(fill="x", padx=12, pady=(0, 4))
add_entry = ctk.CTkEntry(
add_row, placeholder_text="http://ip:port or socks5://ip:port",
font=(FONT, 10), fg_color=BG, border_color=ACCENT, height=28,
)
add_entry.pack(side="left", fill="x", expand=True, padx=(0, 4))
def _add_proxy() -> None:
v = add_entry.get().strip()
if v and ("://" in v or ":" in v):
if "://" not in v:
v = "http://" + v
manual_chain.append(v)
_rebuild_chain_ui()
add_entry.delete(0, "end")
else:
_log("Invalid proxy format — use http://ip:port or socks5://ip:port")
if not v:
return
if "://" not in v:
v = "http://" + v
manual_chain.append(v)
_rebuild_chain_ui()
add_entry.delete(0, "end")
_btn(add_row, "+ Add", _add_proxy, w=70, h=28).pack(side="left")
_btn(add_row, "+ Add", _add_proxy, w=68, h=28).pack(side="left")
def _paste_from_current() -> None:
def _paste_current() -> None:
for h in svc.current_chain:
if h not in manual_chain:
manual_chain.append(h)
_rebuild_chain_ui()
_log("Pasted active chain into manual chain editor.")
_log("Active chain pasted into manual editor.")
_btn(cb_right, "↙ Paste current chain", _paste_from_current, w=200, h=26,
_btn(cb_right, "↙ Paste active chain", _paste_current, w=180, h=26,
fg_color=DIM, hover_color=ACCENT).pack(pady=(0, 10))
# Sources section
# Sources
src_frame = ctk.CTkFrame(tab_chain, fg_color=CARD, corner_radius=8)
src_frame.pack(fill="x", pady=(0, 6))
src_hdr = ctk.CTkFrame(src_frame, fg_color="transparent")
src_hdr.pack(fill="x", padx=10, pady=(8, 4))
ctk.CTkLabel(src_hdr, text="Proxy Sources (one URL per line)",
font=(FONT, 12, "bold"), text_color=TEXT).pack(side="left")
sources_box = ctk.CTkTextbox(src_frame, height=80, font=("Consolas", 10),
fg_color=BG, text_color=TEXT,
scrollbar_button_color=ACCENT)
sources_box.pack(fill="x", padx=10, pady=(0, 8))
ctk.CTkLabel(src_frame, text="Proxy Sources (one JSON URL per line)",
font=(FONT, 11, "bold"), text_color=TEXT).pack(
anchor="w", padx=12, pady=(8, 4))
sources_box = ctk.CTkTextbox(
src_frame, height=72, font=("Consolas", 10),
fg_color=BG, text_color=TEXT, scrollbar_button_color=ACCENT,
)
sources_box.pack(fill="x", padx=12, pady=(0, 8))
sources_box.insert("end", "\n".join(s.sources))
# ─── TAB: SETTINGS ───────────────────────────────────────────────────────
tab_settings.configure(fg_color=BG)
sf_outer = ctk.CTkScrollableFrame(tab_settings, fg_color=BG,
scrollbar_button_color=ACCENT)
sf_outer.pack(fill="both", expand=True)
def _section(title: str) -> ctk.CTkFrame:
ctk.CTkLabel(sf_outer, text=title, font=(FONT, 12, "bold"), text_color=CYAN).pack(
anchor="w", padx=6, pady=(14, 4))
ctk.CTkLabel(sf_outer, text=title, font=(FONT, 12, "bold"),
text_color=CYAN).pack(anchor="w", padx=6, pady=(14, 4))
f = ctk.CTkFrame(sf_outer, fg_color=CARD, corner_radius=8)
f.pack(fill="x", padx=6, pady=(0, 4))
return f
@@ -488,44 +577,50 @@ def main() -> None:
def _fld(parent: Any, label: str, key: str, val: str, tip: str = "") -> None:
row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=10, pady=4)
ctk.CTkLabel(row, text=label, font=(FONT, 11), text_color=TEXT, width=220,
anchor="w").pack(side="left")
e = ctk.CTkEntry(row, height=26, font=(FONT, 11), fg_color=BG,
border_color=ACCENT, width=120)
row.pack(fill="x", padx=12, pady=5)
ctk.CTkLabel(row, text=label, font=(FONT, 11), text_color=TEXT,
width=230, anchor="w").pack(side="left")
e = ctk.CTkEntry(row, height=26, font=(FONT, 11),
fg_color=BG, border_color=ACCENT, width=130)
e.insert(0, val)
e.pack(side="left")
if tip:
ctk.CTkLabel(row, text=f" {tip}", font=(FONT, 10), text_color=TEXT2).pack(side="left")
ctk.CTkLabel(row, text=f" {tip}", font=(FONT, 10),
text_color=TEXT2).pack(side="left")
entries[key] = e
net = _section("Network")
_fld(net, "Local port", "port", str(s.local_port), "(apps point here)")
_fld(net, "Proxy bypass list", "bypass", s.proxy_bypass, "(semicolon separated)")
_fld(net, "Local listener port", "port", str(s.local_port), "(point apps here)")
_fld(net, "Proxy bypass list", "bypass", s.proxy_bypass, "(semicolons)")
_fld(net, "IP check URL", "check_url", s.ip_check_url)
timing = _section("Timing")
_fld(timing, "Health check interval (sec)", "health", str(s.health_check_seconds), "(0 = manual only)")
_fld(timing, "Full list refresh (sec)", "refresh", str(s.full_refresh_seconds))
_fld(timing, "Health check interval (sec)", "health", str(s.health_check_seconds),
"(how often exit IP is re-verified)")
_fld(timing, "Full pool refresh (sec)", "refresh", str(s.full_refresh_seconds),
"(re-fetch + re-validate)")
validation = _section("Validation")
_fld(validation, "Concurrent proxy tests", "conc", str(s.validation_concurrency))
_fld(validation, "Max candidates per cycle", "maxc", str(s.max_candidates))
_fld(validation, "Per-proxy timeout (sec)", "timeout", str(s.validation_timeout_seconds))
val_sec = _section("Validation")
_fld(val_sec, "Concurrent validators", "conc", str(s.validation_concurrency))
_fld(val_sec, "Max candidates per cycle", "maxc", str(s.max_candidates),
"(random sample from fetch)")
_fld(val_sec, "Per-proxy timeout (sec)", "timeout", str(s.validation_timeout_seconds))
security = _section("Security")
ks_row = ctk.CTkFrame(security, fg_color="transparent")
ks_row.pack(fill="x", padx=10, pady=6)
sec_sec = _section("Security")
ks_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
ks_row.pack(fill="x", padx=12, pady=8)
ks_var = ctk.BooleanVar(value=s.kill_switch_enabled)
ctk.CTkCheckBox(ks_row, text="Firewall kill-switch (block all traffic if proxy chain dies)",
variable=ks_var, font=(FONT, 11),
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT).pack(side="left")
ctk.CTkCheckBox(
ks_row,
text="Firewall kill-switch (block ALL traffic if chain is down — requires Admin)",
variable=ks_var, font=(FONT, 11),
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
).pack(side="left")
save_btn_frame = ctk.CTkFrame(sf_outer, fg_color="transparent")
save_btn_frame.pack(fill="x", padx=6, pady=8)
_btn(save_btn_frame, "💾 Save All Settings", lambda: _save_settings(verbose=True),
w=220, h=36, font=(FONT, 13)).pack(side="left")
ctk.CTkFrame(sf_outer, fg_color="transparent", height=4).pack()
_btn(sf_outer, "💾 Save All Settings",
lambda: _save_settings(verbose=True), w=220, h=36,
font=(FONT, 13)).pack(anchor="w", padx=6, pady=8)
# ─────────────────────────────────────────────────────────────────────────
# SAVE SETTINGS
@@ -533,21 +628,27 @@ def main() -> None:
def _save_settings(verbose: bool = False) -> None:
try:
src_raw = sources_box.get("1.0", "end").strip()
src_list = [ln.strip() for ln in src_raw.splitlines() if ln.strip().startswith("http")]
src_list = [ln.strip() for ln in src_raw.splitlines()
if ln.strip().startswith("http")]
n_hops = _hop_count[0]
hop_slider.set(n_hops)
hop_val_lbl.configure(text=str(n_hops))
hop_count_lbl.configure(text=str(n_hops))
ns = Settings(
local_host=LISTEN_HOST,
local_port=int(entries["port"].get().strip()),
chain_length=int(hop_slider.get()),
chain_length=n_hops,
obfuscation_mode=mode_var.get(),
use_pinned_chain=bool(use_manual_var.get()),
pinned_chain=list(manual_chain),
health_check_seconds=int(entries["health"].get().strip()),
full_refresh_seconds=int(entries["refresh"].get().strip()),
validation_concurrency=int(entries["conc"].get().strip()),
max_candidates=int(entries["maxc"].get().strip()),
validation_timeout_seconds=float(entries["timeout"].get().strip()),
prefer_elite=bool(elite_var2.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())),
max_candidates=max(10, int(entries["maxc"].get().strip())),
validation_timeout_seconds=max(2.0, float(entries["timeout"].get().strip())),
prefer_elite=bool(elite_var.get()),
kill_switch_enabled=bool(ks_var.get()),
proxy_bypass=entries["bypass"].get().strip() or Settings().proxy_bypass,
sources=src_list or Settings().sources,
@@ -557,11 +658,11 @@ def main() -> None:
raise ValueError("Port must be 1-65535")
svc.update_settings(ns)
save_settings(ns)
proxy_lbl.configure(text=f"proxy: {LISTEN_HOST}:{ns.local_port}")
proxy_lbl.configure(text=f"{LISTEN_HOST}:{ns.local_port}")
if verbose:
_log("✓ Settings saved.")
except Exception as e:
_log(f"Settings error: {e!s}")
_log(f"Settings error: {e!s}")
# ─────────────────────────────────────────────────────────────────────────
# STATUS REFRESHERS
@@ -582,8 +683,10 @@ def main() -> None:
# ─────────────────────────────────────────────────────────────────────────
def _handle(m: dict[str, Any]) -> None:
t = m.get("type")
if t == "log":
_log(str(m.get("text", "")))
elif t == "state":
running = bool(m.get("running"))
status_dot.configure(text_color=GREEN if running else RED)
@@ -592,35 +695,68 @@ def main() -> None:
if not running:
prog_bar.set(0)
phase_lbl.configure(text="idle")
countdown_lbl.configure(text="next: —", text_color=TEXT2)
tray.set_state("gray")
v_exit_ip.set("")
v_hops.set("")
v_rotation.set("0")
elif t == "phase":
phase_lbl.configure(text=str(m.get("phase", "")))
if m.get("phase") != "validate":
ph = str(m.get("phase", ""))
phase_lbl.configure(text=ph)
if ph == "validate":
prog_bar.configure(progress_color=YELLOW)
elif ph == "fetch":
prog_bar.configure(progress_color=ORANGE)
prog_bar.set(0.15)
elif ph == "running":
prog_bar.configure(progress_color=GREEN)
prog_bar.set(1.0)
else:
prog_bar.set(0)
elif t == "validate_progress":
d, tot = int(m.get("done", 0)), max(1, int(m.get("total", 1)))
prog_bar.set(d / tot)
elif t == "pool":
cnt = int(m.get("count", 0))
pool_lbl.configure(text=f"pool: {cnt}")
v_pool.set(str(cnt))
elif t == "real_ip":
ip = str(m.get("ip", ""))
your_ip_lbl.configure(text=f"Your IP: {ip}")
v_real_ip.set(ip)
elif t == "firewall":
_refresh_fw(m.get("engaged"))
_refresh_fw(bool(m.get("engaged")))
elif t == "rotation":
n = str(m.get("n", 0))
rotation_lbl.configure(text=f"rot: {n}")
v_rotation.set(n)
elif t == "countdown":
secs = int(m.get("secs", 0))
if secs > 0:
m_s, s_s = divmod(secs, 60)
countdown_lbl.configure(
text=f"next: {m_s}m{s_s:02d}s",
text_color=YELLOW if secs < 30 else TEXT2,
)
else:
countdown_lbl.configure(text="next: checking…", text_color=YELLOW)
elif t == "hops":
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)
_refresh_sysproxy()
tray.set_state({"healthy": "green", "dead": "red", "connecting": "yellow"}.get(status, "yellow"))
tray.set_state(
{"healthy": "green", "dead": "red", "connecting": "yellow"}.get(status, "yellow")
)
v_exit_ip.set(str(exit_ip) if exit_ip else "")
v_hops.set(str(len(hops)))
def _pump() -> None:
try:
@@ -628,7 +764,7 @@ def main() -> None:
_handle(ui_q.get_nowait())
except queue.Empty:
pass
root.after(100, _pump)
root.after(80, _pump)
# ─────────────────────────────────────────────────────────────────────────
# CLOSE / MINIMIZE
@@ -638,7 +774,7 @@ def main() -> None:
tray.stop()
svc.stop()
clear_system_proxy()
if is_admin():
if is_admin() and fw_is_engaged():
fw_disengage()
root.destroy()
@@ -651,10 +787,11 @@ def main() -> None:
_refresh_sysproxy()
_refresh_fw()
_log("" * 60)
_log("Proxy God v2 — ready.")
_log(f"Admin: {'YES' if is_admin() else 'NO (Run as Admin for kill-switch)'}")
_log(f"Log: {LOG_PATH}")
_log("Proxy God v2 — ready.")
_log(f"Admin: {'YES — kill-switch available' if is_admin() else 'NO — run as Admin for kill-switch'}")
_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("" * 60)
_pump()
root.mainloop()

View File

@@ -68,12 +68,14 @@ def build_gost_cmd(gost: Path, listen_http: str, forwards: list[str]) -> list[st
def popen_no_window(args: list[str]) -> subprocess.Popen:
# Use DEVNULL for both pipes — GOST writes logs to stderr, leaving pipes
# unread would fill the OS buffer and deadlock the child process.
cr = getattr(subprocess, "CREATE_NO_WINDOW", 0)
return subprocess.Popen(
args,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=cr,
)

View File

@@ -6,6 +6,7 @@ import random
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from .config import Settings, load_settings, save_settings
@@ -19,18 +20,18 @@ log = logging.getLogger(__name__)
Notify = Callable[[dict[str, Any]], None]
# Thread pool for blocking I/O (proxy list fetching) so we don't stall asyncio
_FETCH_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="fetcher")
def _filter_by_mode(pool: list[str], mode: str) -> list[str]:
"""Filter pool by obfuscation mode."""
"""Filter pool by obfuscation/protocol mode."""
if mode == "http_only":
return [u for u in pool if u.startswith("http://")]
if mode == "socks5_only":
return [u for u in pool if u.startswith("socks5://")]
if mode == "random_mix":
random.shuffle(pool)
return pool
# auto — keep all
return pool
# "random_mix" and "auto" — keep all, caller will shuffle
return list(pool)
class ChainService:
@@ -45,6 +46,12 @@ class ChainService:
self._settings = load_settings()
self._current_chain: list[str] = []
# Shuffle-and-drain pool state — never repeat a proxy within a cycle
self._available: list[str] = [] # proxies not yet used this cycle
self._used: set[str] = set() # proxies used this cycle
# Per-session blacklist: proxies that crashed GOST immediately
self._blacklist: set[str] = set()
@property
def settings(self) -> Settings:
return self._settings
@@ -61,7 +68,9 @@ class ChainService:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run_thread, name="ChainService", daemon=True)
self._thread = threading.Thread(
target=self._run_thread, name="ChainService", daemon=True
)
self._thread.start()
def stop(self) -> None:
@@ -76,6 +85,8 @@ class ChainService:
def rotate_now(self) -> None:
self._force_rotate.set()
# ─────────────────────────────────────────────────────────────────────────
def _teardown_network(self) -> None:
clear_system_proxy()
self._notify({"type": "log", "text": "System proxy cleared."})
@@ -96,19 +107,23 @@ class ChainService:
self._teardown_network()
self._notify({"type": "state", "running": False})
# ─────────────────────────────────────────────────────────────────────────
# MAIN ASYNC LOOP
# ─────────────────────────────────────────────────────────────────────────
async def _async_main(self) -> None:
self._notify({"type": "state", "running": True})
# ── GOST setup ───────────────────────────────────────────────────────
try:
gost = ensure_gost()
self._notify({"type": "log", "text": f"GOST ready: {gost}"})
except Exception as e:
self._notify({"type": "log", "text": f"GOST setup failed: {e} — check internet connection."})
self._notify({"type": "log", "text": f"GOST setup failed: {e}"})
return
# Verify GOST isn't quarantined by checking file size
if gost.stat().st_size < 10_000:
self._notify({"type": "log", "text": "GOST exe looks invalid (may be quarantined). Re-downloading..."})
self._notify({"type": "log", "text": "GOST appears quarantined. Re-downloading..."})
gost.unlink(missing_ok=True)
try:
gost = ensure_gost()
@@ -116,121 +131,143 @@ class ChainService:
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
return
# ── Real IP ──────────────────────────────────────────────────────────
real_ip = await get_direct_ip(self._settings.ip_check_url)
if real_ip:
self._notify({"type": "real_ip", "ip": real_ip})
self._notify({"type": "log", "text": f"Your IP (without chain): {real_ip}"})
self._notify({"type": "log", "text": f"Your real IP: {real_ip}"})
else:
self._notify({"type": "log", "text": "Could not determine real IP — exit IP comparison disabled."})
self._notify({"type": "log", "text": "Could not determine real IP — leak detection disabled."})
# Engage firewall kill-switch if enabled
# ── Firewall kill-switch ──────────────────────────────────────────────
if self._settings.kill_switch_enabled:
if is_admin():
ok, msg = fw_engage(gost)
self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": ok})
else:
self._notify({"type": "log", "text": "Kill-switch skipped not running as Administrator."})
self._notify({"type": "log", "text": "Kill-switch skipped (not Admin)."})
self._notify({"type": "firewall", "engaged": False})
else:
self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
self._notify({"type": "firewall", "engaged": False})
# ── Main rotation loop ────────────────────────────────────────────────
full_pool: list[str] = []
last_full = 0.0
pool: list[str] = []
rotation_num = 0
while not self._stop.is_set():
now = time.monotonic()
need_refresh = (
not pool
not full_pool
or now - last_full >= float(self._settings.full_refresh_seconds)
)
# If using pinned chain, skip pool management
if self._settings.use_pinned_chain and len(self._settings.pinned_chain) >= 1:
# Pinned (manual) chain mode — skip pool management
if self._settings.use_pinned_chain and self._settings.pinned_chain:
chain = list(self._settings.pinned_chain)
await self._run_chain(gost, chain, real_ip)
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
ok = await self._run_chain(gost, chain, real_ip)
if not ok:
await self._sleep_interruptible(10)
if self._stop.is_set():
break
continue
# ── Pool refresh ─────────────────────────────────────────────────
if need_refresh:
self._notify({"type": "phase", "phase": "fetch"})
pool = await self._build_pool()
full_pool = await self._build_pool()
last_full = time.monotonic()
self._notify({"type": "pool", "count": len(pool)})
self._available = list(full_pool)
random.shuffle(self._available)
self._used.clear()
self._notify({"type": "pool", "count": len(full_pool)})
filtered = _filter_by_mode(list(pool), self._settings.obfuscation_mode)
if len(filtered) < 2:
self._notify({
"type": "log",
"text": (
f"Pool has {len(filtered)} proxies for mode '{self._settings.obfuscation_mode}'. "
"Try 'auto' mode or increase max candidates. Retrying..."
)
})
await asyncio.sleep(30)
if not full_pool:
self._notify({"type": "log", "text": "Empty pool — retrying in 60s..."})
await self._sleep_interruptible(60)
last_full = 0.0
continue
chain = self._pick_chain(filtered)
# ── Pick next chain (shuffle-and-drain, no repeats per cycle) ────
chain = self._pick_chain()
if not chain:
# Pool exhausted for this cycle — reshuffle and restart
self._notify({"type": "log", "text": "Pool cycle complete — reshuffling for next round."})
self._available = list(full_pool)
random.shuffle(self._available)
self._used.clear()
chain = self._pick_chain()
if not chain:
await self._sleep_interruptible(15)
continue
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
await self._run_chain(gost, chain, real_ip)
if self._stop.is_set():
break
terminate_process(self._proc)
self._proc = None
async def _run_chain(self, gost: Any, chain: list[str], real_ip: str | None) -> None:
"""Spin up GOST with the given chain, monitor it, return when chain dies or rotation triggered."""
# ─────────────────────────────────────────────────────────────────────────
# CHAIN RUNNER
# ─────────────────────────────────────────────────────────────────────────
async def _run_chain(
self, gost: Any, chain: list[str], real_ip: str | None
) -> bool:
"""Start GOST with chain, verify exit IP, monitor until rotation/stop.
Returns True if chain ran successfully, False if it immediately failed."""
self._current_chain = list(chain)
self._notify({"type": "hops", "hops": chain, "status": "connecting"})
listen = self._settings.listen_addr()
cmd = build_gost_cmd(gost, listen, chain)
self._notify({"type": "log", "text": "Starting: " + "".join(self._short(h) for h in chain)})
self._notify({
"type": "log",
"text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: "
+ "".join(self._short(h) for h in chain),
})
terminate_process(self._proc)
self._proc = popen_no_window(cmd)
# Brief settle time
await asyncio.sleep(2.0)
# Check GOST didn't immediately die
if self._proc.poll() is not None:
stderr = b""
try:
_, stderr = self._proc.communicate(timeout=2)
except Exception:
pass
err_msg = stderr.decode(errors="replace").strip() if stderr else "unknown error"
# GOST died immediately — blacklist these proxies
for h in chain:
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})
self._notify({"type": "log", "text": f"GOST exited immediately: {err_msg}"})
return
self._notify({"type": "log", "text": "GOST exited immediately — proxies blacklisted."})
return False
local_proxy = f"http://{listen}"
exit_ip = await check_chain_exit_ip(
local_proxy,
self._settings.ip_check_url,
min(25.0, self._settings.validation_timeout_seconds + 10.0),
)
timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0)
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
if not exit_ip:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
self._notify({"type": "log", "text": "Chain failed IP check. Rotating."})
self._notify({"type": "log", "text": "Chain IP check failed. Rotating."})
terminate_process(self._proc)
self._proc = None
return
return False
if real_ip and exit_ip == real_ip:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"Exit IP {exit_ip} == real IP! Chain leaking. Rotating."})
self._notify({"type": "log", "text": f"Leak! Exit={exit_ip} == real IP. Rotating."})
terminate_process(self._proc)
self._proc = None
return
return False
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"Chain healthy — Exit IP: {exit_ip}"})
self._notify({"type": "log", "text": f"Chain healthy — Exit IP: {exit_ip}"})
set_system_proxy(
self._settings.local_host,
self._settings.local_port,
@@ -238,32 +275,127 @@ class ChainService:
)
self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"})
# Monitor loop
refresh_deadline = time.monotonic() + float(self._settings.full_refresh_seconds)
while not self._stop.is_set() and time.monotonic() < refresh_deadline:
w = await self._wait_health_interval()
if w in ("stop", "rotate"):
# ── Health monitor loop ───────────────────────────────────────────────
while not self._stop.is_set():
result = await self._wait_health_interval()
if result in ("stop", "rotate"):
break
if self._proc.poll() is not None:
self._notify({"type": "log", "text": "GOST process died; rebuilding."})
if self._proc is None or self._proc.poll() is not None:
self._notify({"type": "log", "text": "GOST process died rebuilding."})
break
exit_ip = await check_chain_exit_ip(
local_proxy,
self._settings.ip_check_url,
min(25.0, self._settings.validation_timeout_seconds + 10.0),
)
self._notify({"type": "log", "text": "Health check..."})
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
if not exit_ip or (real_ip and exit_ip == real_ip):
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": "Health check failed; rotating."})
self._notify({"type": "log", "text": "Health check failed rotating."})
break
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"✓ Still healthy — Exit IP: {exit_ip}"})
terminate_process(self._proc)
self._proc = None
return True
# ─────────────────────────────────────────────────────────────────────────
# POOL MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────
def _pick_chain(self) -> list[str]:
"""Pick chain_length proxies from _available (shuffle-and-drain).
Filters out blacklisted proxies. Marks picked ones as used."""
s = self._settings
k = max(1, s.chain_length)
# 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 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)
if len(candidates) < k:
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)
return picked
async def _build_pool(self) -> list[str]:
"""Fetch and validate proxies. Runs blocking I/O in thread pool."""
s = self._settings
loop = asyncio.get_event_loop()
# Fetch all sources concurrently in thread pool (they are blocking)
async def _fetch_one(url: str) -> list[str]:
try:
rows = await loop.run_in_executor(
_FETCH_POOL, lambda: fetch_proxy_json(url)
)
entries = normalize_entries(rows, s.prefer_elite)
self._notify({"type": "log", "text": f"Fetched {len(entries)} from source."})
return entries
except Exception as e:
self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
return []
results = await asyncio.gather(*(_fetch_one(u) for u in s.sources))
raw_urls: list[str] = []
for chunk in results:
raw_urls.extend(chunk)
if not raw_urls:
self._notify({"type": "log", "text": "No proxies fetched from any source!"})
return []
# Deduplicate, remove blacklisted, shuffle before capping
seen: set[str] = set()
unique: list[str] = []
for u in raw_urls:
if u not in seen and u not in self._blacklist:
seen.add(u)
unique.append(u)
random.shuffle(unique)
if len(unique) > s.max_candidates:
unique = unique[: s.max_candidates]
self._notify({"type": "phase", "phase": "validate"})
self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."})
def on_prog(done: int, total: int) -> None:
self._notify({"type": "validate_progress", "done": done, "total": total})
good = await validate_proxies(
unique,
s.ip_check_url,
s.validation_concurrency,
s.validation_timeout_seconds,
on_progress=on_prog,
)
random.shuffle(good)
self._notify({"type": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"})
self._notify({"type": "phase", "phase": "running"})
return good
# ─────────────────────────────────────────────────────────────────────────
# UTILITIES
# ─────────────────────────────────────────────────────────────────────────
async def _wait_health_interval(self) -> str | None:
"""Sleep for health_check_seconds. Returns 'stop', 'rotate', or None."""
total = float(self._settings.health_check_seconds)
end = time.monotonic() + total
while time.monotonic() < end:
@@ -271,68 +403,27 @@ class ChainService:
return "stop"
if self._force_rotate.is_set():
self._force_rotate.clear()
self._notify({"type": "log", "text": "Manual rotate."})
self._notify({"type": "log", "text": "Manual rotate triggered."})
return "rotate"
await asyncio.sleep(0.2)
remaining = end - time.monotonic()
self._notify({"type": "countdown", "secs": max(0, int(remaining))})
await asyncio.sleep(1.0)
self._notify({"type": "countdown", "secs": 0})
return None
def _pick_chain(self, pool: list[str]) -> list[str]:
k = min(self._settings.chain_length, len(pool))
return random.sample(pool, k=k)
async def _sleep_interruptible(self, seconds: float) -> None:
end = time.monotonic() + seconds
while time.monotonic() < end:
if self._stop.is_set():
return
await asyncio.sleep(0.2)
@staticmethod
def _short(url: str) -> str:
return (
url = (
url.replace("http://", "")
.replace("socks5://", "s5://")
.replace("socks4://", "s4://")
.replace("https://", "https://")
.replace("https://", "")
)
async def _build_pool(self) -> list[str]:
s = self._settings
raw_urls: list[str] = []
for url in s.sources:
if self._stop.is_set():
break
try:
rows = fetch_proxy_json(url)
entries = normalize_entries(rows, s.prefer_elite)
raw_urls.extend(entries)
self._notify({"type": "log", "text": f"Fetched {len(rows)} entries from source."})
except Exception as e:
self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
if not raw_urls:
self._notify({"type": "log", "text": "No proxies fetched from any source!"})
return []
# Deduplicate
seen: set[str] = set()
unique: list[str] = []
for u in raw_urls:
if u not in seen:
seen.add(u)
unique.append(u)
raw_urls = unique
if len(raw_urls) > s.max_candidates:
raw_urls = random.sample(raw_urls, k=s.max_candidates)
self._notify({"type": "phase", "phase": "validate"})
self._notify({"type": "log", "text": f"Validating {len(raw_urls)} candidates..."})
def on_prog(done: int, total: int) -> None:
self._notify({"type": "validate_progress", "done": done, "total": total})
good = await validate_proxies(
raw_urls,
s.ip_check_url,
s.validation_concurrency,
s.validation_timeout_seconds,
on_progress=on_prog,
)
random.shuffle(good)
self._notify({"type": "log", "text": f"Valid: {len(good)} / {len(raw_urls)}"})
self._notify({"type": "phase", "phase": "running"})
return good
return url[:30] + "" if len(url) > 32 else url

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
import logging
from typing import Callable
@@ -8,6 +9,33 @@ import httpx
log = logging.getLogger(__name__)
# Secondary fallback endpoints for IP resolution
_IP_FALLBACKS = [
"https://api.ipify.org?format=json",
"https://httpbin.org/ip",
"https://ifconfig.me/ip",
]
def _parse_ip(text: str) -> str | None:
"""Parse an IP string from JSON or plain-text response body."""
body = text.strip()
if not body:
return None
if body.startswith("{"):
try:
d = json.loads(body)
ip = d.get("ip") or d.get("origin") or d.get("query")
return str(ip).split(",")[0].strip() if ip else None
except Exception:
return None
# Plain-text: must look like an IP (short, no HTML)
if len(body) < 50 and "<" not in body:
candidate = body.split()[0].strip()
if candidate.count(".") >= 3 or ":" in candidate:
return candidate
return None
async def validate_proxies(
proxy_urls: list[str],
@@ -38,9 +66,14 @@ async def validate_proxies(
async def _check_one(proxy_url: str, check_url: str, timeout_seconds: float) -> bool:
t = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds))
try:
t = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds))
async with httpx.AsyncClient(proxy=proxy_url, timeout=t, verify=True, follow_redirects=True) as c:
async with httpx.AsyncClient(
proxy=proxy_url,
timeout=t,
verify=False, # many proxies have self-signed or no TLS
follow_redirects=True,
) as c:
r = await c.get(check_url)
return r.status_code == 200 and len(r.content) > 0
except Exception:
@@ -48,49 +81,55 @@ async def _check_one(proxy_url: str, check_url: str, timeout_seconds: float) ->
async def check_chain_exit_ip(
listen_proxy: str, check_url: str, timeout_seconds: float
listen_proxy: str,
check_url: str,
timeout_seconds: float,
) -> str | None:
"""Query the IP-check URL through the local chain proxy.
Returns the exit IP string on success, or None on failure."""
Returns the exit IP string on success, or None on failure.
Tries the configured URL first, then falls back to alternatives."""
urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url]
t = httpx.Timeout(timeout_seconds, connect=min(10.0, timeout_seconds))
try:
t = httpx.Timeout(timeout_seconds, connect=min(10.0, timeout_seconds))
async with httpx.AsyncClient(proxy=listen_proxy, timeout=t, verify=True, follow_redirects=True) as c:
r = await c.get(check_url)
if r.status_code != 200:
return None
body = r.text.strip()
# ipify returns {"ip":"x.x.x.x"} in json mode
if body.startswith("{"):
import json
async with httpx.AsyncClient(
proxy=listen_proxy,
timeout=t,
verify=False,
follow_redirects=True,
) as c:
for url in urls:
try:
return json.loads(body).get("ip") or json.loads(body).get("origin")
r = await c.get(url)
if r.status_code == 200:
ip = _parse_ip(r.text)
if ip:
return ip
except Exception:
return None
# plain text mode
if body and len(body) < 50:
return body
return None
continue
except Exception:
return None
pass
return None
async def get_direct_ip(check_url: str, timeout_seconds: float = 15.0) -> str | None:
"""Get our own exit IP without the proxy chain (so we can compare)."""
urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url]
t = httpx.Timeout(timeout_seconds)
try:
t = httpx.Timeout(timeout_seconds)
async with httpx.AsyncClient(timeout=t, verify=True, follow_redirects=True) as c:
r = await c.get(check_url)
if r.status_code != 200:
return None
body = r.text.strip()
if body.startswith("{"):
import json
async with httpx.AsyncClient(
timeout=t,
verify=False,
follow_redirects=True,
) as c:
for url in urls:
try:
return json.loads(body).get("ip") or json.loads(body).get("origin")
r = await c.get(url)
if r.status_code == 200:
ip = _parse_ip(r.text)
if ip:
return ip
except Exception:
return None
if body and len(body) < 50:
return body
return None
continue
except Exception:
return None
pass
return None