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._base">concurrent.futures._base</a>
|
||||||
• <a href="#concurrent.futures.process">concurrent.futures.process</a>
|
• <a href="#concurrent.futures.process">concurrent.futures.process</a>
|
||||||
• <a href="#concurrent.futures.thread">concurrent.futures.thread</a>
|
• <a href="#concurrent.futures.thread">concurrent.futures.thread</a>
|
||||||
|
• <a href="#proxy_chain_manager.service">proxy_chain_manager.service</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -8630,6 +8631,7 @@ imported by:
|
|||||||
• <a href="#http.cookiejar">http.cookiejar</a>
|
• <a href="#http.cookiejar">http.cookiejar</a>
|
||||||
• <a href="#httpx._client">httpx._client</a>
|
• <a href="#httpx._client">httpx._client</a>
|
||||||
• <a href="#httpx._models">httpx._models</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>
|
• <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>
|
||||||
• <a href="#customtkinter">customtkinter</a>
|
• <a href="#customtkinter">customtkinter</a>
|
||||||
|
• <a href="#datetime">datetime</a>
|
||||||
• <a href="#logging">logging</a>
|
• <a href="#logging">logging</a>
|
||||||
• <a href="#proxy_chain_manager">proxy_chain_manager</a>
|
• <a href="#proxy_chain_manager">proxy_chain_manager</a>
|
||||||
• <a href="#proxy_chain_manager.config">proxy_chain_manager.config</a>
|
• <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>
|
||||||
• <a href="#asyncio">asyncio</a>
|
• <a href="#asyncio">asyncio</a>
|
||||||
• <a href="#collections.abc">collections.abc</a>
|
• <a href="#collections.abc">collections.abc</a>
|
||||||
|
• <a href="#concurrent.futures">concurrent.futures</a>
|
||||||
• <a href="#logging">logging</a>
|
• <a href="#logging">logging</a>
|
||||||
• <a href="#proxy_chain_manager">proxy_chain_manager</a>
|
• <a href="#proxy_chain_manager">proxy_chain_manager</a>
|
||||||
• <a href="#proxy_chain_manager.config">proxy_chain_manager.config</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
|
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()
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user