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:
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.
Binary file not shown.
Binary file not shown.
@@ -6903,6 +6903,7 @@ imported by:
|
||||
• <a href="#concurrent.futures._base">concurrent.futures._base</a>
|
||||
• <a href="#concurrent.futures.process">concurrent.futures.process</a>
|
||||
• <a href="#concurrent.futures.thread">concurrent.futures.thread</a>
|
||||
• <a href="#proxy_chain_manager.service">proxy_chain_manager.service</a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -8630,6 +8631,7 @@ imported by:
|
||||
• <a href="#http.cookiejar">http.cookiejar</a>
|
||||
• <a href="#httpx._client">httpx._client</a>
|
||||
• <a href="#httpx._models">httpx._models</a>
|
||||
• <a href="#proxy_chain_manager.app">proxy_chain_manager.app</a>
|
||||
• <a href="#xmlrpc.client">xmlrpc.client</a>
|
||||
|
||||
</div>
|
||||
@@ -16776,6 +16778,7 @@ imported by:
|
||||
imports:
|
||||
<a href="#__future__">__future__</a>
|
||||
• <a href="#customtkinter">customtkinter</a>
|
||||
• <a href="#datetime">datetime</a>
|
||||
• <a href="#logging">logging</a>
|
||||
• <a href="#proxy_chain_manager">proxy_chain_manager</a>
|
||||
• <a href="#proxy_chain_manager.config">proxy_chain_manager.config</a>
|
||||
@@ -16921,6 +16924,7 @@ imports:
|
||||
<a href="#__future__">__future__</a>
|
||||
• <a href="#asyncio">asyncio</a>
|
||||
• <a href="#collections.abc">collections.abc</a>
|
||||
• <a href="#concurrent.futures">concurrent.futures</a>
|
||||
• <a href="#logging">logging</a>
|
||||
• <a href="#proxy_chain_manager">proxy_chain_manager</a>
|
||||
• <a href="#proxy_chain_manager.config">proxy_chain_manager.config</a>
|
||||
|
||||
BIN
dist/ProxyChainManager.exe
vendored
BIN
dist/ProxyChainManager.exe
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user