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._base">concurrent.futures._base</a>
&#8226; <a href="#concurrent.futures.process">concurrent.futures.process</a> &#8226; <a href="#concurrent.futures.process">concurrent.futures.process</a>
&#8226; <a href="#concurrent.futures.thread">concurrent.futures.thread</a> &#8226; <a href="#concurrent.futures.thread">concurrent.futures.thread</a>
&#8226; <a href="#proxy_chain_manager.service">proxy_chain_manager.service</a>
</div> </div>
@@ -8630,6 +8631,7 @@ imported by:
&#8226; <a href="#http.cookiejar">http.cookiejar</a> &#8226; <a href="#http.cookiejar">http.cookiejar</a>
&#8226; <a href="#httpx._client">httpx._client</a> &#8226; <a href="#httpx._client">httpx._client</a>
&#8226; <a href="#httpx._models">httpx._models</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> &#8226; <a href="#xmlrpc.client">xmlrpc.client</a>
</div> </div>
@@ -16776,6 +16778,7 @@ imported by:
imports: imports:
<a href="#__future__">__future__</a> <a href="#__future__">__future__</a>
&#8226; <a href="#customtkinter">customtkinter</a> &#8226; <a href="#customtkinter">customtkinter</a>
&#8226; <a href="#datetime">datetime</a>
&#8226; <a href="#logging">logging</a> &#8226; <a href="#logging">logging</a>
&#8226; <a href="#proxy_chain_manager">proxy_chain_manager</a> &#8226; <a href="#proxy_chain_manager">proxy_chain_manager</a>
&#8226; <a href="#proxy_chain_manager.config">proxy_chain_manager.config</a> &#8226; <a href="#proxy_chain_manager.config">proxy_chain_manager.config</a>
@@ -16921,6 +16924,7 @@ imports:
<a href="#__future__">__future__</a> <a href="#__future__">__future__</a>
&#8226; <a href="#asyncio">asyncio</a> &#8226; <a href="#asyncio">asyncio</a>
&#8226; <a href="#collections.abc">collections.abc</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="#logging">logging</a>
&#8226; <a href="#proxy_chain_manager">proxy_chain_manager</a> &#8226; <a href="#proxy_chain_manager">proxy_chain_manager</a>
&#8226; <a href="#proxy_chain_manager.config">proxy_chain_manager.config</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 Tabs: Live | Chain Builder | Settings
""" """
from __future__ import annotations from __future__ import annotations
@@ -7,6 +7,7 @@ from __future__ import annotations
import logging import logging
import queue import queue
import sys import sys
from datetime import datetime
from typing import Any from typing import Any
import customtkinter as ctk import customtkinter as ctk
@@ -19,7 +20,12 @@ from .config import (
load_settings, load_settings,
save_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 .paths import app_data_dir
from .service import ChainService from .service import ChainService
from .sysproxy import clear_system_proxy, is_system_proxy_set from .sysproxy import clear_system_proxy, is_system_proxy_set
@@ -45,6 +51,7 @@ ACCENT2 = "#2563eb"
GREEN = "#00e676" GREEN = "#00e676"
RED = "#ff1744" RED = "#ff1744"
YELLOW = "#ffab00" YELLOW = "#ffab00"
ORANGE = "#ff6d00"
PURPLE = "#bb86fc" PURPLE = "#bb86fc"
BLUE = "#448aff" BLUE = "#448aff"
CYAN = "#00bcd4" CYAN = "#00bcd4"
@@ -54,14 +61,18 @@ TEXT2 = "#94a3b8"
FONT = "Segoe UI" FONT = "Segoe UI"
def _ts() -> str:
return datetime.now().strftime("%H:%M:%S")
def main() -> None: def main() -> None:
ctk.set_appearance_mode("dark") ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("dark-blue") ctk.set_default_color_theme("dark-blue")
root = ctk.CTk() root = ctk.CTk()
root.title("Proxy God v2") root.title("Proxy God v2")
root.geometry("1060x720") root.geometry("1080x740")
root.minsize(900, 600) root.minsize(900, 620)
root.configure(fg_color=BG) root.configure(fg_color=BG)
ui_q: queue.Queue[dict[str, Any]] = queue.Queue() ui_q: queue.Queue[dict[str, Any]] = queue.Queue()
@@ -69,126 +80,187 @@ def main() -> None:
s = load_settings() s = load_settings()
# ── tray ───────────────────────────────────────────────────────────────── # ── tray ─────────────────────────────────────────────────────────────────
def _tray_show() -> None: tray = TrayIcon(
root.after(0, lambda: (root.deiconify(), root.lift())) on_show=lambda: root.after(0, lambda: (root.deiconify(), root.lift())),
on_quit=lambda: root.after(0, _close),
def _tray_quit() -> None: on_rotate=svc.rotate_now,
root.after(0, _close) )
tray = TrayIcon(on_show=_tray_show, on_quit=_tray_quit, on_rotate=svc.rotate_now)
tray.start() 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 # HELPERS
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
def _btn(parent: Any, label: str, cmd: Any, w: int = 80, h: int = 28, **kw: Any) -> ctk.CTkButton: 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) cfg.update(kw)
return ctk.CTkButton( return ctk.CTkButton(
parent, text=label, command=cmd, width=w, height=h, parent, text=label, command=cmd, width=w, height=h,
font=(FONT, 11), corner_radius=6, **cfg, 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 # 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(fill="x", side="top")
topbar.pack_propagate(False) topbar.pack_propagate(False)
# Status
status_dot = ctk.CTkLabel(topbar, text="", font=(FONT, 20), text_color=RED) status_dot = ctk.CTkLabel(topbar, text="", font=(FONT, 20), text_color=RED)
status_dot.pack(side="left", padx=(10, 2)) status_dot.pack(side="left", padx=(10, 2))
status_txt = ctk.CTkLabel(topbar, text="STOPPED", font=(FONT, 12, "bold"), text_color=TEXT) 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: def _start() -> None:
_save_settings() _save_settings()
svc.start() svc.start()
_btn(topbar, " Start", _start, w=90).pack(side="left", padx=2) _btn(topbar, "▶ Start", _start, w=86).pack(side="left", padx=2)
_btn(topbar, " Stop", svc.stop, w=80).pack(side="left", padx=2) _btn(topbar, "■ Stop", svc.stop, w=76).pack(side="left", padx=2)
_btn(topbar, " Rotate", svc.rotate_now, w=80).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=55, _btn(topbar, "Quit", lambda: root.after(0, _close), w=52,
fg_color="#7f1d1d", hover_color="#991b1b").pack(side="left", padx=(8, 2)) 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="", 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 = ctk.CTkLabel(topbar, text="Boot:?", font=(FONT, 10), text_color=TEXT2)
boot_lbl.pack(side="left", padx=2) boot_lbl.pack(side="left", padx=2)
def _refresh_boot() -> None: def _refresh_boot() -> None:
on = task_exists() 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) text_color=GREEN if on else DIM)
def _inst_boot() -> None: def _inst_boot() -> None:
ok, msg = install_logon_task() 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() _refresh_boot()
def _rm_boot() -> None: def _rm_boot() -> None:
ok, msg = uninstall_logon_task() 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() _refresh_boot()
_btn(topbar, "Boot+", _inst_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=55).pack(side="left", padx=2) _btn(topbar, "Boot", _rm_boot, w=54).pack(side="left", padx=2)
# Right side status indicators # Right side indicators
proxy_lbl = ctk.CTkLabel(topbar, text=f"proxy: {LISTEN_HOST}:{s.local_port}", 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) 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 = 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 = ctk.CTkLabel(topbar, text="SYS:—", font=(FONT, 10), text_color=DIM)
sys_lbl.pack(side="right", padx=4) sys_lbl.pack(side="right", padx=4)
admin_lbl = ctk.CTkLabel(topbar, admin_badge = ctk.CTkLabel(
text="⚡ADMIN" if is_admin() else "👤USER", topbar,
text="⚡ ADMIN" if is_admin() else "👤 USER",
font=(FONT, 10, "bold"), font=(FONT, 10, "bold"),
text_color=GREEN if is_admin() else YELLOW) text_color=GREEN if is_admin() else YELLOW,
admin_lbl.pack(side="right", padx=4) )
admin_badge.pack(side="right", padx=4)
if not is_admin(): if not is_admin():
def _elevate() -> None: def _elevate() -> None:
if request_admin_relaunch(): if request_admin_relaunch():
_close() _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) fg_color="#7f1d1d", hover_color="#991b1b").pack(side="right", padx=4)
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# CHAIN VIZ BAR # 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(fill="x")
viz_bar.pack_propagate(False) viz_bar.pack_propagate(False)
your_ip_lbl = ctk.CTkLabel(viz_bar, text="Your IP: —", font=(FONT, 10), text_color=TEXT2) 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 = 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: —", exit_ip_lbl = ctk.CTkLabel(viz_bar, text="Exit: —",
font=(FONT, 13, "bold"), text_color=YELLOW) 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] = [] hop_dot_labels: list[ctk.CTkLabel] = []
@@ -199,7 +271,7 @@ def main() -> None:
c = YELLOW if step % 2 == 0 else DIM c = YELLOW if step % 2 == 0 else DIM
for d in hop_dot_labels: for d in hop_dot_labels:
d.configure(text_color=c) 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: def _render_chain(hops: list[str], status: str, exit_ip: str | None) -> None:
_cancel_pulse() _cancel_pulse()
@@ -207,12 +279,14 @@ def main() -> None:
w.destroy() w.destroy()
hop_dot_labels.clear() hop_dot_labels.clear()
color_map = {"healthy": GREEN, "dead": RED, "connecting": YELLOW} hop_c = {"healthy": GREEN, "dead": RED, "connecting": YELLOW}.get(status, DIM)
hop_c = color_map.get(status, DIM)
def _node(text: str, color: str, bold: bool = False) -> None: def _node(text: str, color: str, bold: bool = False) -> None:
f = (FONT, 11, "bold") if bold else (FONT, 10) ctk.CTkLabel(
ctk.CTkLabel(chain_canvas, text=text, font=f, text_color=color).pack(side="left", padx=1) chain_canvas, text=text,
font=(FONT, 11, "bold") if bold else (FONT, 10),
text_color=color,
).pack(side="left", padx=1)
def _arr() -> None: def _arr() -> None:
ctk.CTkLabel(chain_canvas, text="", font=(FONT, 11), text_color=DIM).pack(side="left", padx=2) ctk.CTkLabel(chain_canvas, text="", font=(FONT, 11), text_color=DIM).pack(side="left", padx=2)
@@ -233,14 +307,10 @@ def main() -> None:
_arr() _arr()
_node("WEB", BLUE, bold=True) _node("WEB", BLUE, bold=True)
if exit_ip:
exit_ip_lbl.configure( exit_ip_lbl.configure(
text=f"Exit: {exit_ip}", text=f"Exit: {exit_ip}" if exit_ip else "Exit: —",
text_color=GREEN if status == "healthy" else RED, text_color=GREEN if status == "healthy" else (RED if exit_ip else YELLOW),
) )
else:
exit_ip_lbl.configure(text="Exit: —",
text_color=YELLOW if status == "connecting" else DIM)
if status == "connecting" and hop_dot_labels: if status == "connecting" and hop_dot_labels:
_pulse() _pulse()
@@ -250,153 +320,175 @@ def main() -> None:
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
prog_strip = ctk.CTkFrame(root, fg_color=BG, height=22) prog_strip = ctk.CTkFrame(root, fg_color=BG, height=22)
prog_strip.pack(fill="x", padx=8, pady=(4, 0)) 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) phase_lbl.pack(side="left", padx=4)
prog_bar = ctk.CTkProgressBar(prog_strip, height=8, corner_radius=4, prog_bar = ctk.CTkProgressBar(prog_strip, height=8, corner_radius=4,
fg_color=CARD, progress_color=BLUE) fg_color=CARD, progress_color=BLUE)
prog_bar.pack(side="left", fill="x", expand=True, padx=4) prog_bar.pack(side="left", fill="x", expand=True, padx=4)
prog_bar.set(0) 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) pool_lbl.pack(side="right", padx=4)
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# TABVIEW # TABVIEW
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
tabs = ctk.CTkTabview(root, fg_color=BG, segmented_button_fg_color=CARD, tabs = ctk.CTkTabview(
root,
fg_color=BG,
segmented_button_fg_color=CARD,
segmented_button_selected_color=ACCENT2, segmented_button_selected_color=ACCENT2,
segmented_button_unselected_color=CARD, segmented_button_unselected_color=CARD,
segmented_button_selected_hover_color=ACCENT, segmented_button_selected_hover_color=ACCENT,
border_width=0) border_width=0,
)
tabs.pack(fill="both", expand=True, padx=8, pady=(4, 8)) tabs.pack(fill="both", expand=True, padx=8, pady=(4, 8))
tab_live = tabs.add(" Live ") tab_live = tabs.add(" Live ")
tab_chain = tabs.add(" Chain Builder ") tab_chain = tabs.add(" Chain Builder ")
tab_settings = tabs.add(" Settings ") tab_settings = tabs.add(" Settings ")
for tab in (tab_live, tab_chain, tab_settings):
tab.configure(fg_color=BG)
# ─── TAB: LIVE ─────────────────────────────────────────────────────────── # ─── TAB: LIVE ───────────────────────────────────────────────────────────
tab_live.configure(fg_color=BG) # Stats cards row
# Stats row
stats_row = ctk.CTkFrame(tab_live, fg_color="transparent") stats_row = ctk.CTkFrame(tab_live, fg_color="transparent")
stats_row.pack(fill="x", pady=(4, 6)) 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 = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=8)
f.pack(side="left", fill="x", expand=True, padx=4) 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, text=title, font=(FONT, 9), text_color=TEXT2).pack(
ctk.CTkLabel(f, textvariable=var, font=(FONT, 15, "bold"), text_color=TEXT).pack(anchor="w", padx=8, pady=(0, 6)) 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_real_ip = ctk.StringVar(value="")
v_exit_ip = ctk.StringVar(value="") v_exit_ip = ctk.StringVar(value="")
v_pool = ctk.StringVar(value="0") v_pool = ctk.StringVar(value="0")
v_hops = ctk.StringVar(value="") v_rotation = ctk.StringVar(value="0")
_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)
_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 = ctk.CTkFrame(tab_live, fg_color=CARD, corner_radius=8)
log_frame.pack(fill="both", expand=True, padx=0) log_frame.pack(fill="both", expand=True)
log_box = ctk.CTkTextbox(log_frame, font=("Consolas", 11), fg_color=BG, log_box = ctk.CTkTextbox(
text_color=TEXT, scrollbar_button_color=ACCENT) 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) log_box.pack(fill="both", expand=True, padx=4, pady=4)
def _log(line: str) -> None: def _log(line: str) -> None:
log_box.insert("end", line + "\n") log_box.insert("end", f"[{_ts()}] {line}\n")
log_box.see("end") log_box.see("end")
# ─── TAB: CHAIN BUILDER ────────────────────────────────────────────────── # ─── 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") # Left — mode + hop slider
cb_top.pack(fill="x", pady=(4, 8)) cb_left = ctk.CTkFrame(cb_split, fg_color=CARD, corner_radius=8)
# Left col — mode + hops
cb_left = ctk.CTkFrame(cb_top, fg_color=CARD, corner_radius=8)
cb_left.pack(side="left", fill="both", expand=True, padx=(0, 6)) 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( ctk.CTkLabel(cb_left, text="Obfuscation Mode", font=(FONT, 12, "bold"),
anchor="w", padx=10, pady=(10, 4)) text_color=TEXT).pack(anchor="w", padx=12, pady=(12, 4))
mode_var = ctk.StringVar(value=s.obfuscation_mode) mode_var = ctk.StringVar(value=s.obfuscation_mode)
for mode_key in OBFUSCATION_MODES: for mk in OBFUSCATION_MODES:
ctk.CTkRadioButton( ctk.CTkRadioButton(
cb_left, cb_left,
text=OBFUSCATION_LABELS[mode_key], text=OBFUSCATION_LABELS[mk],
variable=mode_var, variable=mode_var,
value=mode_key, value=mk,
font=(FONT, 11), font=(FONT, 11),
fg_color=ACCENT2, fg_color=ACCENT2,
hover_color=ACCENT, hover_color=ACCENT,
text_color=TEXT, 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.CTkFrame(cb_left, fg_color="transparent", height=8).pack()
ctk.CTkLabel(cb_left, text="Hop Count", font=(FONT, 12, "bold"), text_color=TEXT).pack( ctk.CTkLabel(cb_left, text="Hop Count (also: topbar +/)",
anchor="w", padx=10) font=(FONT, 12, "bold"), text_color=TEXT).pack(anchor="w", padx=12)
hop_val_lbl = ctk.CTkLabel(cb_left, text=str(s.chain_length), hop_val_lbl = ctk.CTkLabel(cb_left, text=str(_hop_count[0]),
font=(FONT, 22, "bold"), text_color=CYAN) font=(FONT, 26, "bold"), text_color=CYAN)
hop_val_lbl.pack(pady=(2, 0)) hop_val_lbl.pack(pady=(2, 0))
def _on_hop_slider(val: float) -> None: 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, hop_slider = ctk.CTkSlider(
cb_left, from_=1, to=8, number_of_steps=7,
command=_on_hop_slider, command=_on_hop_slider,
fg_color=CARD, progress_color=ACCENT2, fg_color=PANEL, progress_color=ACCENT2,
button_color=CYAN, button_hover_color=BLUE) button_color=CYAN, button_hover_color=BLUE,
)
hop_slider.set(s.chain_length) 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) hop_legend = ctk.CTkFrame(cb_left, fg_color="transparent")
ctk.CTkCheckBox(cb_left, text="Elite proxies only", hop_legend.pack(fill="x", padx=16, pady=(0, 8))
variable=elite_var2, font=(FONT, 11), ctk.CTkLabel(hop_legend, text="1", font=(FONT, 9), text_color=DIM).pack(side="left")
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT).pack( ctk.CTkLabel(hop_legend, text="8", font=(FONT, 9), text_color=DIM).pack(side="right")
anchor="w", padx=14, pady=(0, 10))
# Right col — manual chain editor elite_var = ctk.BooleanVar(value=s.prefer_elite)
cb_right = ctk.CTkFrame(cb_top, fg_color=CARD, corner_radius=8, width=380) 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) cb_right.pack(side="left", fill="both", expand=True)
hdr_row = ctk.CTkFrame(cb_right, fg_color="transparent") hdr_row = ctk.CTkFrame(cb_right, fg_color="transparent")
hdr_row.pack(fill="x", padx=10, pady=(10, 4)) 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") 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) use_manual_var = ctk.BooleanVar(value=s.use_pinned_chain)
ctk.CTkCheckBox(hdr_row, text="Use this chain", ctk.CTkCheckBox(
variable=use_manual_var, hdr_row, text="Pin & use this chain",
font=(FONT, 10), fg_color=ACCENT2, variable=use_manual_var, font=(FONT, 10),
hover_color=ACCENT, text_color=TEXT2).pack(side="right") 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))
manual_chain: list[str] = list(s.pinned_chain) 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: def _rebuild_chain_ui() -> None:
for w in list(chain_list_frame.winfo_children()): for w in list(chain_list_frame.winfo_children()):
w.destroy() w.destroy()
row_frames.clear()
for i, hop in enumerate(manual_chain): for i, hop in enumerate(manual_chain):
_chain_row(i, hop) _chain_row(i, hop)
def _chain_row(idx: int, hop: str) -> None: 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(fill="x", pady=2)
fr.pack_propagate(False) fr.pack_propagate(False)
row_frames.append(fr)
# Drag indicator + index
ctk.CTkLabel(fr, text=f" {idx+1}.", font=(FONT, 10, "bold"), 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 "HTTP"
proto = hop.split("://")[0].upper() if "://" in hop else "?"
badge_c = {"HTTP": "#1e3a8a", "SOCKS5": "#064e3b", "SOCKS4": "#3b1a64"}.get(proto, DIM) badge_c = {"HTTP": "#1e3a8a", "SOCKS5": "#064e3b", "SOCKS4": "#3b1a64"}.get(proto, DIM)
ctk.CTkLabel(fr, text=proto, font=(FONT, 9, "bold"), ctk.CTkLabel(fr, text=proto, font=(FONT, 9, "bold"),
fg_color=badge_c, corner_radius=4, padx=4, fg_color=badge_c, corner_radius=4, padx=4,
@@ -419,67 +511,64 @@ def main() -> None:
del manual_chain[i] del manual_chain[i]
_rebuild_chain_ui() _rebuild_chain_ui()
_btn(fr, "", _up, w=24, h=22, fg_color=DIM, hover_color=ACCENT).pack(side="right", padx=1) _btn(fr, "", _up, w=26, h=24, 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, "", _dn, w=26, h=24, 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, "", _rm, w=26, h=24, fg_color="#7f1d1d", hover_color=RED).pack(side="right", padx=(1, 6))
_rebuild_chain_ui() _rebuild_chain_ui()
# Add proxy row # Add proxy input
add_row = ctk.CTkFrame(cb_right, fg_color="transparent") add_row = ctk.CTkFrame(cb_right, fg_color="transparent")
add_row.pack(fill="x", padx=10, pady=(0, 4)) add_row.pack(fill="x", padx=12, pady=(0, 4))
add_entry = ctk.CTkEntry(add_row, placeholder_text="http://1.2.3.4:8080 or socks5://...", add_entry = ctk.CTkEntry(
font=(FONT, 10), fg_color=BG, border_color=ACCENT, height=28) 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)) add_entry.pack(side="left", fill="x", expand=True, padx=(0, 4))
def _add_proxy() -> None: def _add_proxy() -> None:
v = add_entry.get().strip() v = add_entry.get().strip()
if v and ("://" in v or ":" in v): if not v:
return
if "://" not in v: if "://" not in v:
v = "http://" + v v = "http://" + v
manual_chain.append(v) manual_chain.append(v)
_rebuild_chain_ui() _rebuild_chain_ui()
add_entry.delete(0, "end") add_entry.delete(0, "end")
else:
_log("Invalid proxy format — use http://ip:port or socks5://ip:port")
_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: for h in svc.current_chain:
if h not in manual_chain: if h not in manual_chain:
manual_chain.append(h) manual_chain.append(h)
_rebuild_chain_ui() _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)) 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 = ctk.CTkFrame(tab_chain, fg_color=CARD, corner_radius=8)
src_frame.pack(fill="x", pady=(0, 6)) src_frame.pack(fill="x", pady=(0, 6))
ctk.CTkLabel(src_frame, text="Proxy Sources (one JSON URL per line)",
src_hdr = ctk.CTkFrame(src_frame, fg_color="transparent") font=(FONT, 11, "bold"), text_color=TEXT).pack(
src_hdr.pack(fill="x", padx=10, pady=(8, 4)) anchor="w", padx=12, pady=(8, 4))
ctk.CTkLabel(src_hdr, text="Proxy Sources (one URL per line)", sources_box = ctk.CTkTextbox(
font=(FONT, 12, "bold"), text_color=TEXT).pack(side="left") src_frame, height=72, font=("Consolas", 10),
fg_color=BG, text_color=TEXT, scrollbar_button_color=ACCENT,
sources_box = ctk.CTkTextbox(src_frame, height=80, font=("Consolas", 10), )
fg_color=BG, text_color=TEXT, sources_box.pack(fill="x", padx=12, pady=(0, 8))
scrollbar_button_color=ACCENT)
sources_box.pack(fill="x", padx=10, pady=(0, 8))
sources_box.insert("end", "\n".join(s.sources)) sources_box.insert("end", "\n".join(s.sources))
# ─── TAB: SETTINGS ─────────────────────────────────────────────────────── # ─── TAB: SETTINGS ───────────────────────────────────────────────────────
tab_settings.configure(fg_color=BG)
sf_outer = ctk.CTkScrollableFrame(tab_settings, fg_color=BG, sf_outer = ctk.CTkScrollableFrame(tab_settings, fg_color=BG,
scrollbar_button_color=ACCENT) scrollbar_button_color=ACCENT)
sf_outer.pack(fill="both", expand=True) sf_outer.pack(fill="both", expand=True)
def _section(title: str) -> ctk.CTkFrame: def _section(title: str) -> ctk.CTkFrame:
ctk.CTkLabel(sf_outer, text=title, font=(FONT, 12, "bold"), text_color=CYAN).pack( ctk.CTkLabel(sf_outer, text=title, font=(FONT, 12, "bold"),
anchor="w", padx=6, pady=(14, 4)) text_color=CYAN).pack(anchor="w", padx=6, pady=(14, 4))
f = ctk.CTkFrame(sf_outer, fg_color=CARD, corner_radius=8) f = ctk.CTkFrame(sf_outer, fg_color=CARD, corner_radius=8)
f.pack(fill="x", padx=6, pady=(0, 4)) f.pack(fill="x", padx=6, pady=(0, 4))
return f return f
@@ -488,44 +577,50 @@ def main() -> None:
def _fld(parent: Any, label: str, key: str, val: str, tip: str = "") -> None: def _fld(parent: Any, label: str, key: str, val: str, tip: str = "") -> None:
row = ctk.CTkFrame(parent, fg_color="transparent") row = ctk.CTkFrame(parent, fg_color="transparent")
row.pack(fill="x", padx=10, pady=4) row.pack(fill="x", padx=12, pady=5)
ctk.CTkLabel(row, text=label, font=(FONT, 11), text_color=TEXT, width=220, ctk.CTkLabel(row, text=label, font=(FONT, 11), text_color=TEXT,
anchor="w").pack(side="left") width=230, anchor="w").pack(side="left")
e = ctk.CTkEntry(row, height=26, font=(FONT, 11), fg_color=BG, e = ctk.CTkEntry(row, height=26, font=(FONT, 11),
border_color=ACCENT, width=120) fg_color=BG, border_color=ACCENT, width=130)
e.insert(0, val) e.insert(0, val)
e.pack(side="left") e.pack(side="left")
if tip: 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 entries[key] = e
net = _section("Network") net = _section("Network")
_fld(net, "Local port", "port", str(s.local_port), "(apps point here)") _fld(net, "Local listener port", "port", str(s.local_port), "(point apps here)")
_fld(net, "Proxy bypass list", "bypass", s.proxy_bypass, "(semicolon separated)") _fld(net, "Proxy bypass list", "bypass", s.proxy_bypass, "(semicolons)")
_fld(net, "IP check URL", "check_url", s.ip_check_url) _fld(net, "IP check URL", "check_url", s.ip_check_url)
timing = _section("Timing") timing = _section("Timing")
_fld(timing, "Health check interval (sec)", "health", str(s.health_check_seconds), "(0 = manual only)") _fld(timing, "Health check interval (sec)", "health", str(s.health_check_seconds),
_fld(timing, "Full list refresh (sec)", "refresh", str(s.full_refresh_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") val_sec = _section("Validation")
_fld(validation, "Concurrent proxy tests", "conc", str(s.validation_concurrency)) _fld(val_sec, "Concurrent validators", "conc", str(s.validation_concurrency))
_fld(validation, "Max candidates per cycle", "maxc", str(s.max_candidates)) _fld(val_sec, "Max candidates per cycle", "maxc", str(s.max_candidates),
_fld(validation, "Per-proxy timeout (sec)", "timeout", str(s.validation_timeout_seconds)) "(random sample from fetch)")
_fld(val_sec, "Per-proxy timeout (sec)", "timeout", str(s.validation_timeout_seconds))
security = _section("Security") sec_sec = _section("Security")
ks_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
ks_row = ctk.CTkFrame(security, fg_color="transparent") ks_row.pack(fill="x", padx=12, pady=8)
ks_row.pack(fill="x", padx=10, pady=6)
ks_var = ctk.BooleanVar(value=s.kill_switch_enabled) ks_var = ctk.BooleanVar(value=s.kill_switch_enabled)
ctk.CTkCheckBox(ks_row, text="Firewall kill-switch (block all traffic if proxy chain dies)", ctk.CTkCheckBox(
ks_row,
text="Firewall kill-switch (block ALL traffic if chain is down — requires Admin)",
variable=ks_var, font=(FONT, 11), variable=ks_var, font=(FONT, 11),
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT).pack(side="left") fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
).pack(side="left")
save_btn_frame = ctk.CTkFrame(sf_outer, fg_color="transparent") ctk.CTkFrame(sf_outer, fg_color="transparent", height=4).pack()
save_btn_frame.pack(fill="x", padx=6, pady=8) _btn(sf_outer, "💾 Save All Settings",
_btn(save_btn_frame, "💾 Save All Settings", lambda: _save_settings(verbose=True), lambda: _save_settings(verbose=True), w=220, h=36,
w=220, h=36, font=(FONT, 13)).pack(side="left") font=(FONT, 13)).pack(anchor="w", padx=6, pady=8)
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# SAVE SETTINGS # SAVE SETTINGS
@@ -533,21 +628,27 @@ def main() -> None:
def _save_settings(verbose: bool = False) -> None: def _save_settings(verbose: bool = False) -> None:
try: try:
src_raw = sources_box.get("1.0", "end").strip() 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( ns = Settings(
local_host=LISTEN_HOST, local_host=LISTEN_HOST,
local_port=int(entries["port"].get().strip()), local_port=int(entries["port"].get().strip()),
chain_length=int(hop_slider.get()), chain_length=n_hops,
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),
health_check_seconds=int(entries["health"].get().strip()), health_check_seconds=max(10, int(entries["health"].get().strip())),
full_refresh_seconds=int(entries["refresh"].get().strip()), full_refresh_seconds=max(60, int(entries["refresh"].get().strip())),
validation_concurrency=int(entries["conc"].get().strip()), validation_concurrency=max(1, int(entries["conc"].get().strip())),
max_candidates=int(entries["maxc"].get().strip()), max_candidates=max(10, int(entries["maxc"].get().strip())),
validation_timeout_seconds=float(entries["timeout"].get().strip()), validation_timeout_seconds=max(2.0, float(entries["timeout"].get().strip())),
prefer_elite=bool(elite_var2.get()), prefer_elite=bool(elite_var.get()),
kill_switch_enabled=bool(ks_var.get()), kill_switch_enabled=bool(ks_var.get()),
proxy_bypass=entries["bypass"].get().strip() or Settings().proxy_bypass, proxy_bypass=entries["bypass"].get().strip() or Settings().proxy_bypass,
sources=src_list or Settings().sources, sources=src_list or Settings().sources,
@@ -557,11 +658,11 @@ def main() -> None:
raise ValueError("Port must be 1-65535") raise ValueError("Port must be 1-65535")
svc.update_settings(ns) svc.update_settings(ns)
save_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: if verbose:
_log("✓ Settings saved.") _log("✓ Settings saved.")
except Exception as e: except Exception as e:
_log(f"Settings error: {e!s}") _log(f"Settings error: {e!s}")
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# STATUS REFRESHERS # STATUS REFRESHERS
@@ -582,8 +683,10 @@ def main() -> None:
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
def _handle(m: dict[str, Any]) -> None: def _handle(m: dict[str, Any]) -> None:
t = m.get("type") t = m.get("type")
if t == "log": if t == "log":
_log(str(m.get("text", ""))) _log(str(m.get("text", "")))
elif t == "state": elif t == "state":
running = bool(m.get("running")) running = bool(m.get("running"))
status_dot.configure(text_color=GREEN if running else RED) status_dot.configure(text_color=GREEN if running else RED)
@@ -592,35 +695,68 @@ def main() -> None:
if not running: if not running:
prog_bar.set(0) prog_bar.set(0)
phase_lbl.configure(text="idle") phase_lbl.configure(text="idle")
countdown_lbl.configure(text="next: —", text_color=TEXT2)
tray.set_state("gray") tray.set_state("gray")
v_exit_ip.set("") v_exit_ip.set("")
v_hops.set("") v_rotation.set("0")
elif t == "phase": elif t == "phase":
phase_lbl.configure(text=str(m.get("phase", ""))) ph = str(m.get("phase", ""))
if m.get("phase") != "validate": 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) prog_bar.set(0)
elif t == "validate_progress": elif t == "validate_progress":
d, tot = int(m.get("done", 0)), max(1, int(m.get("total", 1))) d, tot = int(m.get("done", 0)), max(1, int(m.get("total", 1)))
prog_bar.set(d / tot) prog_bar.set(d / tot)
elif t == "pool": elif t == "pool":
cnt = int(m.get("count", 0)) cnt = int(m.get("count", 0))
pool_lbl.configure(text=f"pool: {cnt}") pool_lbl.configure(text=f"pool: {cnt}")
v_pool.set(str(cnt)) v_pool.set(str(cnt))
elif t == "real_ip": elif t == "real_ip":
ip = str(m.get("ip", "")) ip = str(m.get("ip", ""))
your_ip_lbl.configure(text=f"Your IP: {ip}") your_ip_lbl.configure(text=f"Your IP: {ip}")
v_real_ip.set(ip) v_real_ip.set(ip)
elif t == "firewall": 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": elif t == "hops":
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) _render_chain(hops, status, exit_ip)
_refresh_sysproxy() _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_exit_ip.set(str(exit_ip) if exit_ip else "")
v_hops.set(str(len(hops)))
def _pump() -> None: def _pump() -> None:
try: try:
@@ -628,7 +764,7 @@ def main() -> None:
_handle(ui_q.get_nowait()) _handle(ui_q.get_nowait())
except queue.Empty: except queue.Empty:
pass pass
root.after(100, _pump) root.after(80, _pump)
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
# CLOSE / MINIMIZE # CLOSE / MINIMIZE
@@ -638,7 +774,7 @@ def main() -> None:
tray.stop() tray.stop()
svc.stop() svc.stop()
clear_system_proxy() clear_system_proxy()
if is_admin(): if is_admin() and fw_is_engaged():
fw_disengage() fw_disengage()
root.destroy() root.destroy()
@@ -652,9 +788,10 @@ def main() -> None:
_refresh_fw() _refresh_fw()
_log("" * 60) _log("" * 60)
_log("Proxy God v2 — ready.") _log("Proxy God v2 — ready.")
_log(f"Admin: {'YES' if is_admin() else 'NO (Run as Admin for kill-switch)'}") _log(f"Admin: {'YES — kill-switch available' if is_admin() else 'NO — run as Admin for kill-switch'}")
_log(f"Log: {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("" * 60) _log("" * 60)
_pump() _pump()
root.mainloop() 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: 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) cr = getattr(subprocess, "CREATE_NO_WINDOW", 0)
return subprocess.Popen( return subprocess.Popen(
args, args,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.DEVNULL,
creationflags=cr, creationflags=cr,
) )

View File

@@ -6,6 +6,7 @@ import random
import threading import threading
import time import time
from collections.abc import Callable from collections.abc import Callable
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, save_settings
@@ -19,18 +20,18 @@ log = logging.getLogger(__name__)
Notify = Callable[[dict[str, Any]], None] 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]: 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": if mode == "http_only":
return [u for u in pool if u.startswith("http://")] return [u for u in pool if u.startswith("http://")]
if mode == "socks5_only": if mode == "socks5_only":
return [u for u in pool if u.startswith("socks5://")] return [u for u in pool if u.startswith("socks5://")]
if mode == "random_mix": # "random_mix" and "auto" — keep all, caller will shuffle
random.shuffle(pool) return list(pool)
return pool
# auto — keep all
return pool
class ChainService: class ChainService:
@@ -45,6 +46,12 @@ class ChainService:
self._settings = load_settings() self._settings = load_settings()
self._current_chain: list[str] = [] 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 @property
def settings(self) -> Settings: def settings(self) -> Settings:
return self._settings return self._settings
@@ -61,7 +68,9 @@ class ChainService:
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
return return
self._stop.clear() 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() self._thread.start()
def stop(self) -> None: def stop(self) -> None:
@@ -76,6 +85,8 @@ class ChainService:
def rotate_now(self) -> None: def rotate_now(self) -> None:
self._force_rotate.set() self._force_rotate.set()
# ─────────────────────────────────────────────────────────────────────────
def _teardown_network(self) -> None: def _teardown_network(self) -> None:
clear_system_proxy() clear_system_proxy()
self._notify({"type": "log", "text": "System proxy cleared."}) self._notify({"type": "log", "text": "System proxy cleared."})
@@ -96,19 +107,23 @@ class ChainService:
self._teardown_network() self._teardown_network()
self._notify({"type": "state", "running": False}) self._notify({"type": "state", "running": False})
# ─────────────────────────────────────────────────────────────────────────
# MAIN ASYNC LOOP
# ─────────────────────────────────────────────────────────────────────────
async def _async_main(self) -> None: async def _async_main(self) -> None:
self._notify({"type": "state", "running": True}) self._notify({"type": "state", "running": True})
# ── GOST setup ───────────────────────────────────────────────────────
try: try:
gost = ensure_gost() gost = ensure_gost()
self._notify({"type": "log", "text": f"GOST ready: {gost}"}) self._notify({"type": "log", "text": f"GOST ready: {gost}"})
except Exception as e: 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 return
# Verify GOST isn't quarantined by checking file size
if gost.stat().st_size < 10_000: 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) gost.unlink(missing_ok=True)
try: try:
gost = ensure_gost() gost = ensure_gost()
@@ -116,121 +131,143 @@ class ChainService:
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"}) self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
return return
# ── Real IP ──────────────────────────────────────────────────────────
real_ip = await get_direct_ip(self._settings.ip_check_url) real_ip = await get_direct_ip(self._settings.ip_check_url)
if real_ip: if real_ip:
self._notify({"type": "real_ip", "ip": 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: 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 self._settings.kill_switch_enabled:
if is_admin(): if is_admin():
ok, msg = fw_engage(gost) ok, msg = fw_engage(gost)
self._notify({"type": "log", "text": msg}) self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": ok}) self._notify({"type": "firewall", "engaged": ok})
else: 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}) self._notify({"type": "firewall", "engaged": False})
else: else:
self._notify({"type": "log", "text": "Kill-switch disabled in settings."}) self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
self._notify({"type": "firewall", "engaged": False}) self._notify({"type": "firewall", "engaged": False})
# ── Main rotation loop ────────────────────────────────────────────────
full_pool: list[str] = []
last_full = 0.0 last_full = 0.0
pool: list[str] = [] rotation_num = 0
while not self._stop.is_set(): while not self._stop.is_set():
now = time.monotonic() now = time.monotonic()
need_refresh = ( need_refresh = (
not pool not full_pool
or now - last_full >= float(self._settings.full_refresh_seconds) or now - last_full >= float(self._settings.full_refresh_seconds)
) )
# If using pinned chain, skip pool management # Pinned (manual) chain mode — skip pool management
if self._settings.use_pinned_chain and len(self._settings.pinned_chain) >= 1: if self._settings.use_pinned_chain and self._settings.pinned_chain:
chain = list(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(): if self._stop.is_set():
break break
continue continue
# ── Pool refresh ─────────────────────────────────────────────────
if need_refresh: if need_refresh:
self._notify({"type": "phase", "phase": "fetch"}) self._notify({"type": "phase", "phase": "fetch"})
pool = await self._build_pool() full_pool = await self._build_pool()
last_full = time.monotonic() 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 not full_pool:
self._notify({"type": "log", "text": "Empty pool — retrying in 60s..."})
if len(filtered) < 2: await self._sleep_interruptible(60)
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)
last_full = 0.0 last_full = 0.0
continue 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) await self._run_chain(gost, chain, real_ip)
if self._stop.is_set(): if self._stop.is_set():
break break
terminate_process(self._proc) terminate_process(self._proc)
self._proc = None 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._current_chain = list(chain)
self._notify({"type": "hops", "hops": chain, "status": "connecting"}) self._notify({"type": "hops", "hops": chain, "status": "connecting"})
listen = self._settings.listen_addr() listen = self._settings.listen_addr()
cmd = build_gost_cmd(gost, listen, chain) 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) terminate_process(self._proc)
self._proc = popen_no_window(cmd) self._proc = popen_no_window(cmd)
# Brief settle time
await asyncio.sleep(2.0) await asyncio.sleep(2.0)
# Check GOST didn't immediately die
if self._proc.poll() is not None: if self._proc.poll() is not None:
stderr = b"" # GOST died immediately — blacklist these proxies
try: for h in chain:
_, stderr = self._proc.communicate(timeout=2) self._blacklist.add(h)
except Exception: self._available = [x for x in self._available if x != h]
pass
err_msg = stderr.decode(errors="replace").strip() if stderr else "unknown error"
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None}) self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
self._notify({"type": "log", "text": f"GOST exited immediately: {err_msg}"}) self._notify({"type": "log", "text": "GOST exited immediately — proxies blacklisted."})
return return False
local_proxy = f"http://{listen}" local_proxy = f"http://{listen}"
exit_ip = await check_chain_exit_ip( timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0)
local_proxy, exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
self._settings.ip_check_url,
min(25.0, self._settings.validation_timeout_seconds + 10.0),
)
if not exit_ip: if not exit_ip:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None}) 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) terminate_process(self._proc)
self._proc = None self._proc = None
return return False
if real_ip and exit_ip == real_ip: if real_ip and exit_ip == real_ip:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_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) terminate_process(self._proc)
self._proc = None self._proc = None
return return False
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip}) 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( set_system_proxy(
self._settings.local_host, self._settings.local_host,
self._settings.local_port, self._settings.local_port,
@@ -238,32 +275,127 @@ class ChainService:
) )
self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"}) self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"})
# Monitor loop # ── Health monitor loop ───────────────────────────────────────────────
refresh_deadline = time.monotonic() + float(self._settings.full_refresh_seconds) while not self._stop.is_set():
while not self._stop.is_set() and time.monotonic() < refresh_deadline: result = await self._wait_health_interval()
w = await self._wait_health_interval() if result in ("stop", "rotate"):
if w in ("stop", "rotate"):
break break
if self._proc.poll() is not None: if self._proc is None or self._proc.poll() is not None:
self._notify({"type": "log", "text": "GOST process died; rebuilding."}) self._notify({"type": "log", "text": "GOST process died rebuilding."})
break break
exit_ip = await check_chain_exit_ip( self._notify({"type": "log", "text": "Health check..."})
local_proxy, exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
self._settings.ip_check_url,
min(25.0, self._settings.validation_timeout_seconds + 10.0),
)
if not exit_ip or (real_ip and exit_ip == real_ip): 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": "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 break
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip}) 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) terminate_process(self._proc)
self._proc = None 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: 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) total = float(self._settings.health_check_seconds)
end = time.monotonic() + total end = time.monotonic() + total
while time.monotonic() < end: while time.monotonic() < end:
@@ -271,68 +403,27 @@ class ChainService:
return "stop" return "stop"
if self._force_rotate.is_set(): if self._force_rotate.is_set():
self._force_rotate.clear() self._force_rotate.clear()
self._notify({"type": "log", "text": "Manual rotate."}) self._notify({"type": "log", "text": "Manual rotate triggered."})
return "rotate" 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 return None
def _pick_chain(self, pool: list[str]) -> list[str]: async def _sleep_interruptible(self, seconds: float) -> None:
k = min(self._settings.chain_length, len(pool)) end = time.monotonic() + seconds
return random.sample(pool, k=k) while time.monotonic() < end:
if self._stop.is_set():
return
await asyncio.sleep(0.2)
@staticmethod @staticmethod
def _short(url: str) -> str: def _short(url: str) -> str:
return ( url = (
url.replace("http://", "") url.replace("http://", "")
.replace("socks5://", "s5://") .replace("socks5://", "s5://")
.replace("socks4://", "s4://") .replace("socks4://", "s4://")
.replace("https://", "https://") .replace("https://", "")
) )
return url[:30] + "" if len(url) > 32 else url
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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
from typing import Callable from typing import Callable
@@ -8,6 +9,33 @@ import httpx
log = logging.getLogger(__name__) 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( async def validate_proxies(
proxy_urls: list[str], 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: async def _check_one(proxy_url: str, check_url: str, timeout_seconds: float) -> bool:
try:
t = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds)) 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: try:
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) r = await c.get(check_url)
return r.status_code == 200 and len(r.content) > 0 return r.status_code == 200 and len(r.content) > 0
except Exception: 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( 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: ) -> str | None:
"""Query the IP-check URL through the local chain proxy. """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.
try: 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)) 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
try: try:
return json.loads(body).get("ip") or json.loads(body).get("origin") async with httpx.AsyncClient(
proxy=listen_proxy,
timeout=t,
verify=False,
follow_redirects=True,
) as c:
for url in urls:
try:
r = await c.get(url)
if r.status_code == 200:
ip = _parse_ip(r.text)
if ip:
return ip
except Exception: except Exception:
return None continue
# plain text mode
if body and len(body) < 50:
return body
return None
except Exception: except Exception:
pass
return None return None
async def get_direct_ip(check_url: str, timeout_seconds: float = 15.0) -> str | 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).""" """Get our own exit IP without the proxy chain (so we can compare)."""
try: urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url]
t = httpx.Timeout(timeout_seconds) 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
try: try:
return json.loads(body).get("ip") or json.loads(body).get("origin") async with httpx.AsyncClient(
timeout=t,
verify=False,
follow_redirects=True,
) as c:
for url in urls:
try:
r = await c.get(url)
if r.status_code == 200:
ip = _parse_ip(r.text)
if ip:
return ip
except Exception: except Exception:
return None continue
if body and len(body) < 50:
return body
return None
except Exception: except Exception:
pass
return None return None