v2: tabs, chain builder, obfuscation modes, hop slider, Defender exclusion, firewall kept

Made-with: Cursor
This commit is contained in:
Dr Jones
2026-04-14 17:13:02 -07:00
commit 4bb626498f
45 changed files with 34053 additions and 0 deletions

46
ProxyChainManager.spec Normal file
View File

@@ -0,0 +1,46 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = []
binaries = []
hiddenimports = ['pystray._win32']
tmp_ret = collect_all('customtkinter')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['run.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='ProxyChainManager',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
uac_admin=True,
)

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# Proxy God — Rotating Proxy Chain Manager for Windows
Auto-fetches, validates and rotates multi-hop proxy chains with a live GUI, Windows kill-switch firewall, system proxy enforcement, and tray icon. Works on top of NordVPN (or any VPN) as the outer tunnel.
```
YOU → NordVPN (OS tunnel) → Hop 1 → Hop 2 → Hop 3 → Internet
```
---
## Features
- **Auto proxy pool** — fetches from Proxifly CDN every 30 min, validates concurrently
- **Multi-hop GOST chains** — 28 hops, randomly picked from validated pool
- **Obfuscation modes** — Auto, HTTP-only, SOCKS5-only, Random Mix
- **Manual chain builder** — drag/reorder hops, pin your own IPs
- **Exit IP verification** — checks real exit IP every health cycle, rotates if it matches your real IP
- **Windows system proxy** — sets `HKCU` registry proxy so all WinINet apps use the chain
- **Firewall kill-switch** — `netsh` rules that block all outbound except GOST + NordVPN when running as Admin
- **Tray icon** — green/yellow/red circle, right-click menu
- **Boot persistence** — Task Scheduler logon task
---
## Requirements
- **Windows 10/11 x64**
- **Python 3.10+** (or use the pre-built `.exe`)
- **NordVPN** (recommended but optional — any VPN works)
---
## Quick Start (from source)
```cmd
git clone https://gitea.thetempleofdoom.com/drjones/proxy-god.git
cd proxy-god
pip install -r requirements.txt
python run.py
```
> Run as **Administrator** for full kill-switch firewall enforcement.
---
## Build standalone `.exe`
```cmd
build_exe.bat
```
Output: `dist\ProxyChainManager.exe` + copied to Desktop.
---
## Usage
1. Launch `ProxyChainManager.exe` (accept UAC prompt for Admin)
2. Configure settings in **Settings** tab if needed (defaults work fine)
3. Click **▶ Start** — the app will:
- Download GOST (one time, ~9 MB) and add Defender exclusion
- Fetch proxy lists from Proxifly CDN
- Validate them concurrently
- Build a random N-hop chain
- Verify the exit IP is different from your real IP
- Set Windows system proxy
- Engage firewall kill-switch (Admin only)
4. Close window → minimizes to tray (chain keeps running)
5. Click **Quit** or tray → Quit to fully stop
---
## Chain Builder Tab
- **Obfuscation mode** — selects which protocols to include in chains
- **Hop count slider** — 28 hops
- **Manual chain** — enter proxies manually, reorder with ↑↓, enable "Use this chain" to pin it
- **Paste current** — copies the auto-selected chain into the editor
- **Sources** — add/remove proxy list URLs (JSON format from Proxifly)
---
## Settings Reference
| Setting | Default | Description |
|---|---|---|
| Local port | 18888 | HTTP proxy port — point apps here |
| Health check sec | 180 | How often to re-verify exit IP |
| Full refresh sec | 1800 | How often to re-fetch + re-validate |
| Concurrency | 64 | Parallel proxy validators |
| Max candidates | 400 | Max proxies sampled for validation per cycle |
| Timeout sec | 12 | Per-proxy validation timeout |
| Kill-switch | ON | Block all outbound traffic except GOST + Nord |
---
## Architecture
```
app.py GUI (CustomTkinter, 3 tabs)
service.py Background thread — pool management, GOST lifecycle, health check
gost_util.py Downloads and manages gost.exe
firewall.py netsh kill-switch rules
sysproxy.py Windows registry system proxy (WinINet broadcast)
tray.py pystray tray icon
config.py Settings dataclass + JSON persistence
validator.py Async proxy validation + exit IP checking
fetcher.py Proxifly JSON fetcher
```
---
## Proxy Sources
Default sources use [proxifly/free-proxy-list](https://github.com/proxifly/free-proxy-list) CDN:
- `https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json`
- `https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/socks5/data.json`
- `https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/https/data.json`
You can add any URL returning a JSON array of `{"proxy":"http://ip:port", ...}` objects.
---
## Notes
- Free public proxies are **untrusted** — do not send sensitive unencrypted data
- NordVPN (or any VPN) as the OS tunnel means the proxy operators only see the VPN exit IP, not your ISP IP
- Kill-switch requires **Admin** — UAC prompt appears on launch
- Works on cloned VMs — all listeners bind to `127.0.0.1`, not the machine's LAN IP

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.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,88 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named 'org.python' - imported by copy (optional), xml.sax (delayed, conditional)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named org - imported by pickle (optional)
missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional)
missing module named resource - imported by posix (top-level)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional)
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), netrc (delayed, conditional), getpass (delayed)
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
missing module named fcntl - imported by subprocess (optional)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named termios - imported by getpass (optional)
missing module named 'java.lang' - imported by platform (delayed, optional), xml.sax._exceptions (conditional)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named pyimod02_importers - imported by C:\Python311\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
missing module named annotationlib - imported by typing_extensions (conditional)
missing module named numpy - imported by PIL._typing (conditional, optional)
missing module named olefile - imported by PIL.FpxImagePlugin (top-level), PIL.MicImagePlugin (top-level)
missing module named defusedxml - imported by PIL.Image (optional)
missing module named PyObjCTools - imported by darkdetect._mac_detect (optional)
missing module named Foundation - imported by darkdetect._mac_detect (optional), pystray._darwin (top-level)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named _winreg - imported by platform (delayed, optional)
missing module named 'gi.repository' - imported by pystray._appindicator (top-level), pystray._util.gtk (top-level), pystray._util.notify_dbus (top-level), pystray._gtk (top-level)
missing module named gi - imported by pystray._appindicator (top-level), pystray._util.gtk (top-level), pystray._util.notify_dbus (top-level), pystray._gtk (top-level)
missing module named 'Xlib.XK' - imported by pystray._xorg (top-level)
missing module named 'Xlib.threaded' - imported by pystray._xorg (top-level)
missing module named Xlib - imported by pystray._xorg (top-level)
missing module named StringIO - imported by six (conditional)
missing module named 'PyObjCTools.MachSignals' - imported by pystray._darwin (top-level)
missing module named objc - imported by pystray._darwin (top-level)
missing module named AppKit - imported by pystray._darwin (top-level)
runtime module named six.moves - imported by pystray._base (top-level), pystray._win32 (top-level), pystray._xorg (top-level)
missing module named trio - imported by httpx._transports.asgi (delayed, conditional), httpcore._synchronization (optional), httpcore._backends.trio (top-level)
missing module named 'trio.testing' - imported by anyio._backends._trio (delayed)
missing module named exceptiongroup - imported by anyio._core._exceptions (conditional), anyio._core._sockets (conditional), anyio._backends._asyncio (conditional), anyio._backends._trio (conditional)
missing module named _typeshed - imported by anyio.abc._eventloop (conditional), anyio._core._sockets (conditional), anyio._core._fileio (conditional), anyio._core._tempfile (conditional), httpx._transports.wsgi (conditional), anyio._backends._asyncio (conditional), anyio._core._asyncio_selector_thread (conditional), anyio._backends._trio (conditional)
missing module named 'trio.to_thread' - imported by anyio._backends._trio (top-level)
missing module named 'trio.socket' - imported by anyio._backends._trio (top-level)
missing module named outcome - imported by anyio._backends._trio (top-level)
missing module named 'trio.lowlevel' - imported by anyio._backends._trio (top-level)
missing module named 'trio.from_thread' - imported by anyio._backends._trio (top-level)
missing module named _pytest - imported by anyio._backends._asyncio (delayed)
missing module named winloop - imported by anyio._backends._asyncio (delayed, conditional)
missing module named uvloop - imported by anyio._backends._asyncio (delayed, conditional)
missing module named sniffio - imported by httpx._transports.asgi (delayed, optional), anyio._core._eventloop (optional), httpcore._synchronization (delayed, optional)
missing module named 'h2.settings' - imported by httpcore._sync.http2 (top-level), httpcore._async.http2 (top-level)
missing module named 'h2.exceptions' - imported by httpcore._sync.http2 (top-level), httpcore._async.http2 (top-level)
missing module named 'h2.events' - imported by httpcore._sync.http2 (top-level), httpcore._async.http2 (top-level)
missing module named 'h2.connection' - imported by httpcore._sync.http2 (top-level), httpcore._async.http2 (top-level)
missing module named h2 - imported by httpcore._sync.http2 (top-level), httpx._client (delayed, conditional, optional)
missing module named 'h2.config' - imported by httpcore._async.http2 (top-level)
missing module named 'rich.table' - imported by httpx._main (top-level)
missing module named 'rich.syntax' - imported by httpx._main (top-level)
missing module named 'rich.progress' - imported by httpx._main (top-level)
missing module named 'rich.markup' - imported by httpx._main (top-level)
missing module named rich - imported by httpx._main (top-level)
missing module named 'pygments.util' - imported by httpx._main (top-level)
missing module named pygments - imported by httpx._main (top-level)
missing module named click - imported by httpx._main (top-level)
missing module named '_typeshed.wsgi' - imported by httpx._transports.wsgi (conditional)
missing module named zstandard - imported by httpx._decoders (optional)
missing module named brotlicffi - imported by httpx._decoders (optional)
missing module named brotli - imported by httpx._decoders (optional)

File diff suppressed because it is too large Load Diff

21
build_exe.bat Normal file
View File

@@ -0,0 +1,21 @@
@echo off
setlocal
cd /d "%~dp0"
python -m pip install -r requirements.txt
python -m pip install pyinstaller
python -m PyInstaller --noconfirm --clean ^
--onefile --windowed ^
--uac-admin ^
--name ProxyChainManager ^
--collect-all customtkinter ^
--hidden-import pystray._win32 ^
run.py
copy /Y "dist\ProxyChainManager.exe" "%USERPROFILE%\Desktop\ProxyChainManager.exe" >nul
echo.
echo Built: dist\ProxyChainManager.exe
echo Copied to: %USERPROFILE%\Desktop\ProxyChainManager.exe
endlocal

BIN
dist/ProxyChainManager.exe vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,3 @@
"""Rotating proxy chain manager for Windows (GOST backend)."""
__version__ = "1.0.0"

View File

@@ -0,0 +1,4 @@
from .app import main
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

664
proxy_chain_manager/app.py Normal file
View File

@@ -0,0 +1,664 @@
"""
Proxy Chain Manager — GUI
Tabs: Live | Chain Builder | Settings
"""
from __future__ import annotations
import logging
import queue
import sys
from typing import Any
import customtkinter as ctk
from .config import (
LISTEN_HOST,
OBFUSCATION_LABELS,
OBFUSCATION_MODES,
Settings,
load_settings,
save_settings,
)
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
from .tray import TrayIcon
from .windows_task import install_logon_task, task_exists, uninstall_logon_task
LOG_PATH = app_data_dir() / "proxy_chain_manager.log"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=[
logging.FileHandler(LOG_PATH, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
# ── palette ──────────────────────────────────────────────────────────────────
BG = "#0d0d1a"
CARD = "#12122a"
PANEL = "#1a1a3e"
ACCENT = "#1e3a8a"
ACCENT2 = "#2563eb"
GREEN = "#00e676"
RED = "#ff1744"
YELLOW = "#ffab00"
PURPLE = "#bb86fc"
BLUE = "#448aff"
CYAN = "#00bcd4"
DIM = "#4a5568"
TEXT = "#e2e8f0"
TEXT2 = "#94a3b8"
FONT = "Segoe UI"
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.configure(fg_color=BG)
ui_q: queue.Queue[dict[str, Any]] = queue.Queue()
svc = ChainService(notify=lambda m: ui_q.put(m))
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.start()
# ─────────────────────────────────────────────────────────────────────────
# 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.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.pack(fill="x", side="top")
topbar.pack_propagate(False)
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))
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))
ctk.CTkLabel(topbar, text="", text_color=DIM).pack(side="left", padx=6)
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",
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}")
_refresh_boot()
def _rm_boot() -> None:
ok, msg = uninstall_logon_task()
_log("Boot task removed." if ok else f"Boot remove fail: {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)
# Right side status indicators
proxy_lbl = ctk.CTkLabel(topbar, text=f"proxy: {LISTEN_HOST}:{s.local_port}",
font=(FONT, 11, "bold"), text_color=BLUE)
proxy_lbl.pack(side="right", padx=(4, 12))
fw_lbl = ctk.CTkLabel(topbar, text="FW:—", font=(FONT, 10), text_color=DIM)
fw_lbl.pack(side="right", padx=4)
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)
if not is_admin():
def _elevate() -> None:
if request_admin_relaunch():
_close()
_btn(topbar, "Run as Admin", _elevate, w=100,
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.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)
chain_canvas = ctk.CTkFrame(viz_bar, fg_color="transparent")
chain_canvas.pack(side="left", fill="both", expand=True, padx=4)
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))
hop_dot_labels: list[ctk.CTkLabel] = []
def _pulse(step: int = 0) -> None:
if not hop_dot_labels:
pulse_job["id"] = None
return
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))
def _render_chain(hops: list[str], status: str, exit_ip: str | None) -> None:
_cancel_pulse()
for w in list(chain_canvas.winfo_children()):
w.destroy()
hop_dot_labels.clear()
color_map = {"healthy": GREEN, "dead": RED, "connecting": YELLOW}
hop_c = color_map.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)
def _arr() -> None:
ctk.CTkLabel(chain_canvas, text="", font=(FONT, 11), text_color=DIM).pack(side="left", padx=2)
_node("YOU", GREEN, bold=True)
_arr()
_node("NORD", PURPLE, bold=True)
_arr()
for i, h in enumerate(hops):
dot = ctk.CTkLabel(chain_canvas, text="", font=(FONT, 12), text_color=hop_c)
dot.pack(side="left", padx=1)
hop_dot_labels.append(dot)
ctk.CTkLabel(chain_canvas, text=_short(h), font=(FONT, 10), text_color=hop_c).pack(side="left", padx=1)
if i < len(hops) - 1:
_arr()
_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)
if status == "connecting" and hop_dot_labels:
_pulse()
# ─────────────────────────────────────────────────────────────────────────
# PROGRESS STRIP
# ─────────────────────────────────────────────────────────────────────────
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)
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.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.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 ")
# ─── TAB: LIVE ───────────────────────────────────────────────────────────
tab_live.configure(fg_color=BG)
# Stats 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:
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))
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)
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_box.pack(fill="both", expand=True, padx=4, pady=4)
def _log(line: str) -> None:
log_box.insert("end", line + "\n")
log_box.see("end")
# ─── TAB: CHAIN BUILDER ──────────────────────────────────────────────────
tab_chain.configure(fg_color=BG)
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)
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))
mode_var = ctk.StringVar(value=s.obfuscation_mode)
for mode_key in OBFUSCATION_MODES:
ctk.CTkRadioButton(
cb_left,
text=OBFUSCATION_LABELS[mode_key],
variable=mode_var,
value=mode_key,
font=(FONT, 11),
fg_color=ACCENT2,
hover_color=ACCENT,
text_color=TEXT,
).pack(anchor="w", padx=14, pady=2)
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)
hop_val_lbl = ctk.CTkLabel(cb_left, text=str(s.chain_length),
font=(FONT, 22, "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)))
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.set(s.chain_length)
hop_slider.pack(fill="x", padx=14, pady=(2, 12))
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))
# Right col — manual chain editor
cb_right = ctk.CTkFrame(cb_top, fg_color=CARD, corner_radius=8, width=380)
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")
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))
manual_chain: list[str] = list(s.pinned_chain)
row_frames: list[ctk.CTkFrame] = []
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.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")
# Protocol badge
proto = hop.split("://")[0].upper() if "://" in hop else "?"
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,
text_color=TEXT).pack(side="left", padx=2)
ctk.CTkLabel(fr, text=_short(hop), font=("Consolas", 10),
text_color=TEXT, anchor="w").pack(side="left", fill="x", expand=True, padx=4)
def _up(i: int = idx) -> None:
if i > 0:
manual_chain[i], manual_chain[i-1] = manual_chain[i-1], manual_chain[i]
_rebuild_chain_ui()
def _dn(i: int = idx) -> None:
if i < len(manual_chain) - 1:
manual_chain[i], manual_chain[i+1] = manual_chain[i+1], manual_chain[i]
_rebuild_chain_ui()
def _rm(i: int = idx) -> 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))
_rebuild_chain_ui()
# Add proxy row
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_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")
_btn(add_row, "+ Add", _add_proxy, w=70, h=28).pack(side="left")
def _paste_from_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.")
_btn(cb_right, "↙ Paste current chain", _paste_from_current, w=200, h=26,
fg_color=DIM, hover_color=ACCENT).pack(pady=(0, 10))
# Sources section
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))
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))
f = ctk.CTkFrame(sf_outer, fg_color=CARD, corner_radius=8)
f.pack(fill="x", padx=6, pady=(0, 4))
return f
entries: dict[str, ctk.CTkEntry] = {}
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)
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")
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, "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))
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))
security = _section("Security")
ks_row = ctk.CTkFrame(security, fg_color="transparent")
ks_row.pack(fill="x", padx=10, pady=6)
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")
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")
# ─────────────────────────────────────────────────────────────────────────
# SAVE SETTINGS
# ─────────────────────────────────────────────────────────────────────────
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")]
ns = Settings(
local_host=LISTEN_HOST,
local_port=int(entries["port"].get().strip()),
chain_length=int(hop_slider.get()),
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()),
kill_switch_enabled=bool(ks_var.get()),
proxy_bypass=entries["bypass"].get().strip() or Settings().proxy_bypass,
sources=src_list or Settings().sources,
ip_check_url=entries["check_url"].get().strip() or Settings().ip_check_url,
)
if not (1 <= ns.local_port <= 65535):
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}")
if verbose:
_log("✓ Settings saved.")
except Exception as e:
_log(f"Settings error: {e!s}")
# ─────────────────────────────────────────────────────────────────────────
# STATUS REFRESHERS
# ─────────────────────────────────────────────────────────────────────────
def _refresh_sysproxy() -> None:
on = is_system_proxy_set()
sys_lbl.configure(text="SYS:ON" if on else "SYS:OFF",
text_color=GREEN if on else DIM)
def _refresh_fw(engaged: bool | None = None) -> None:
if engaged is None:
engaged = fw_is_engaged()
fw_lbl.configure(text="FW:ON" if engaged else "FW:OFF",
text_color=GREEN if engaged else DIM)
# ─────────────────────────────────────────────────────────────────────────
# MESSAGE PUMP
# ─────────────────────────────────────────────────────────────────────────
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)
status_txt.configure(text="RUNNING" if running else "STOPPED")
_refresh_sysproxy()
if not running:
prog_bar.set(0)
phase_lbl.configure(text="idle")
tray.set_state("gray")
v_exit_ip.set("")
v_hops.set("")
elif t == "phase":
phase_lbl.configure(text=str(m.get("phase", "")))
if m.get("phase") != "validate":
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"))
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"))
v_exit_ip.set(str(exit_ip) if exit_ip else "")
v_hops.set(str(len(hops)))
def _pump() -> None:
try:
while True:
_handle(ui_q.get_nowait())
except queue.Empty:
pass
root.after(100, _pump)
# ─────────────────────────────────────────────────────────────────────────
# CLOSE / MINIMIZE
# ─────────────────────────────────────────────────────────────────────────
def _close() -> None:
_cancel_pulse()
tray.stop()
svc.stop()
clear_system_proxy()
if is_admin():
fw_disengage()
root.destroy()
root.protocol("WM_DELETE_WINDOW", lambda: root.withdraw())
# ─────────────────────────────────────────────────────────────────────────
# INIT
# ─────────────────────────────────────────────────────────────────────────
_refresh_boot()
_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("Press START to fetch, validate and chain proxies.")
_log("" * 60)
_pump()
root.mainloop()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from .paths import app_data_dir
# Always bind the local forwarder to loopback. VM clones and NIC/IP changes (DHCP)
# do not affect 127.0.0.1; avoid storing LAN IPs or hostnames for the listener.
LISTEN_HOST = "127.0.0.1"
OBFUSCATION_MODES = ["auto", "http_only", "socks5_only", "random_mix"]
OBFUSCATION_LABELS = {
"auto": "Auto (best available)",
"http_only": "HTTP only",
"socks5_only": "SOCKS5 only",
"random_mix": "Random Mix (max noise)",
}
@dataclass
class Settings:
# ── network ──────────────────────────────────────────────────────────
local_host: str = LISTEN_HOST
local_port: int = 18888
# ── chain ─────────────────────────────────────────────────────────────
chain_length: int = 3 # number of hops (2-8)
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
use_pinned_chain: bool = False # use manually ordered chain
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
# ── timing ────────────────────────────────────────────────────────────
health_check_seconds: int = 180 # how often to re-verify exit IP
full_refresh_seconds: int = 1800 # how often to re-fetch/re-validate pool
# ── validation ────────────────────────────────────────────────────────
validation_concurrency: int = 64
max_candidates: int = 400
validation_timeout_seconds: float = 12.0
prefer_elite: bool = False
# ── security ──────────────────────────────────────────────────────────
kill_switch_enabled: bool = True # engage Windows Firewall kill-switch when running
proxy_bypass: str = "localhost;127.*;10.*;192.168.*;<local>"
# ── sources ───────────────────────────────────────────────────────────
sources: list[str] = field(
default_factory=lambda: [
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json",
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/socks5/data.json",
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/https/data.json",
]
)
ip_check_url: str = "https://api.ipify.org?format=json"
def listen_addr(self) -> str:
return f"{self.local_host}:{self.local_port}"
def settings_path(self) -> Path:
return app_data_dir() / "settings.json"
def normalize_listen_host(s: Settings) -> tuple[Settings, bool]:
"""Force loopback bind so LAN/DHCP IP changes (e.g. cloned VMs) never break the listener."""
if s.local_host == LISTEN_HOST:
return s, False
s.local_host = LISTEN_HOST
return s, True
def load_settings() -> Settings:
p = app_data_dir() / "settings.json"
if not p.is_file():
return Settings()
try:
raw = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return Settings()
s = Settings()
for k, v in raw.items():
if hasattr(s, k):
setattr(s, k, v)
if not isinstance(s.sources, list) or not all(isinstance(x, str) for x in s.sources):
s.sources = Settings().sources
if s.obfuscation_mode not in OBFUSCATION_MODES:
s.obfuscation_mode = "auto"
if not isinstance(s.pinned_chain, list):
s.pinned_chain = []
s, changed = normalize_listen_host(s)
if changed:
try:
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")
except OSError:
pass
return s
def save_settings(s: Settings) -> None:
s, _ = normalize_listen_host(s)
p = app_data_dir() / "settings.json"
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import logging
from typing import Any
import httpx
log = logging.getLogger(__name__)
def fetch_proxy_json(url: str, timeout: float = 45.0) -> list[dict[str, Any]]:
with httpx.Client(timeout=timeout, follow_redirects=True) as c:
r = c.get(url)
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
return []
return [x for x in data if isinstance(x, dict)]
def normalize_entries(rows: list[dict[str, Any]], prefer_elite: bool) -> list[str]:
out: list[str] = []
for row in rows:
if prefer_elite and str(row.get("anonymity", "")).lower() != "elite":
continue
p = row.get("proxy")
if isinstance(p, str) and "://" in p:
out.append(p.strip())
# de-dupe preserving order
seen: set[str] = set()
uniq: list[str] = []
for u in out:
if u not in seen:
seen.add(u)
uniq.append(u)
return uniq

View File

@@ -0,0 +1,175 @@
"""
Windows Firewall enforcement — kill-switch style.
When engaged:
- Default outbound policy → BLOCK (all profiles)
- Allow rules for: GOST, NordVPN stack, this app, loopback, DHCP
- Everything else is denied outbound — apps that don't go through the
local proxy simply can't reach the internet.
When disengaged:
- Remove our rules, restore default outbound → ALLOW
"""
from __future__ import annotations
import ctypes
import glob
import logging
import subprocess
import sys
from pathlib import Path
from .paths import gost_exe_path
log = logging.getLogger(__name__)
RULE_PREFIX = "PCM_"
NORD_GLOBS = [
r"C:\Program Files\NordVPN\*.exe",
r"C:\Program Files\NordUpdater\*.exe",
r"C:\Program Files\NordVPN\NordSec ThreatProtection\*.exe",
]
def is_admin() -> bool:
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined]
except Exception:
return False
def request_admin_relaunch() -> bool:
"""Re-launch the current process elevated. Returns True if launched, False on error."""
if is_admin():
return False
try:
exe = sys.executable
args = " ".join(f'"{a}"' for a in sys.argv)
ret = ctypes.windll.shell32.ShellExecuteW( # type: ignore[attr-defined]
None, "runas", exe, args, None, 1
)
return ret > 32
except Exception:
return False
def _run(args: list[str], check: bool = False) -> subprocess.CompletedProcess[str]:
return subprocess.run(
args, capture_output=True, text=True, timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
def _add_rule(name: str, **kw: str) -> bool:
full = RULE_PREFIX + name
args = ["netsh", "advfirewall", "firewall", "add", "rule", f"name={full}"]
for k, v in kw.items():
args.append(f"{k}={v}")
r = _run(args)
ok = r.returncode == 0
if not ok:
log.warning("Firewall rule %s failed: %s", full, (r.stdout or "") + (r.stderr or ""))
return ok
def _delete_rules() -> None:
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", "name=all", "dir=out"])
lines = (r.stdout or "").splitlines()
names: set[str] = set()
for ln in lines:
if ln.startswith("Rule Name:"):
n = ln.split(":", 1)[1].strip()
if n.startswith(RULE_PREFIX):
names.add(n)
for n in names:
_run(["netsh", "advfirewall", "firewall", "delete", "rule", f"name={n}"])
log.info("Deleted %d PCM firewall rules", len(names))
def _set_outbound_policy(action: str) -> None:
"""action = 'blockinbound,blockoutbound' or 'blockinbound,allowoutbound'."""
for profile in ("domainprofile", "privateprofile", "publicprofile"):
_run(["netsh", "advfirewall", "set", profile, "firewallpolicy", action])
def _resolve_self_exe() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve()
return Path(sys.executable).resolve()
def _expand_nord_exes() -> list[str]:
out: list[str] = []
for pattern in NORD_GLOBS:
out.extend(glob.glob(pattern))
return out
def engage(gost_path: Path | None = None) -> tuple[bool, str]:
"""Activate kill-switch firewall. Returns (success, message)."""
if not is_admin():
return False, "Need admin privileges for firewall enforcement."
gost = gost_path or gost_exe_path()
self_exe = _resolve_self_exe()
nord_exes = _expand_nord_exes()
_delete_rules()
# Allow loopback (apps → GOST on 127.0.0.1)
_add_rule("Loopback", dir="out", action="allow",
remoteip="127.0.0.0/8", protocol="any")
# Allow GOST outbound (it connects to the proxy hops)
_add_rule("GOST", dir="out", action="allow",
program=f'"{gost}"', protocol="any")
# Allow this application outbound (fetches proxy lists, validates)
_add_rule("Self", dir="out", action="allow",
program=f'"{self_exe}"', protocol="any")
# If running from python.exe, also allow pythonw.exe
if self_exe.name.lower() in ("python.exe", "pythonw.exe"):
for sibling in ("python.exe", "pythonw.exe"):
p = self_exe.with_name(sibling)
if p.is_file():
_add_rule(f"Python_{sibling}", dir="out", action="allow",
program=f'"{p}"', protocol="any")
# Allow all NordVPN executables
for i, npath in enumerate(nord_exes):
_add_rule(f"Nord_{i}", dir="out", action="allow",
program=f'"{npath}"', protocol="any")
# Allow DHCP (or you lose your adapter)
_add_rule("DHCP", dir="out", action="allow",
protocol="udp", remoteport="67,68")
# Allow DNS (GOST needs to resolve proxy hostnames)
_add_rule("DNS_UDP", dir="out", action="allow",
protocol="udp", remoteport="53")
_add_rule("DNS_TCP", dir="out", action="allow",
protocol="tcp", remoteport="53")
# Set default outbound to BLOCK
_set_outbound_policy("blockinbound,blockoutbound")
log.info("Firewall kill-switch engaged. %d Nord exes whitelisted.", len(nord_exes))
return True, f"Kill-switch ON. {len(nord_exes)} Nord processes whitelisted."
def disengage() -> tuple[bool, str]:
"""Remove kill-switch, restore normal outbound."""
if not is_admin():
return False, "Need admin to remove firewall rules."
_set_outbound_policy("blockinbound,allowoutbound")
_delete_rules()
log.info("Firewall kill-switch disengaged.")
return True, "Kill-switch OFF. Normal outbound restored."
def is_engaged() -> bool:
"""Quick check: are our rules present?"""
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={RULE_PREFIX}GOST", "dir=out"])
return RULE_PREFIX in (r.stdout or "")

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import io
import logging
import shutil
import subprocess
import zipfile
from pathlib import Path
import httpx
from .paths import gost_exe_path
log = logging.getLogger(__name__)
GOST_RELEASE_ZIP = (
"https://github.com/go-gost/gost/releases/download/v3.2.6/"
"gost_3.2.6_windows_amd64.zip"
)
def _add_defender_exclusion(path: Path) -> None:
"""Add Windows Defender exclusion so GOST is not quarantined."""
try:
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
f"Add-MpPreference -ExclusionPath '{path.parent}' -ExclusionProcess 'gost.exe' -ErrorAction SilentlyContinue"],
capture_output=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
log.info("Defender exclusion added for %s", path.parent)
except Exception:
pass # non-fatal
def ensure_gost(target: Path | None = None) -> Path:
exe = target or gost_exe_path()
if exe.is_file() and exe.stat().st_size > 10_000:
return exe
exe.parent.mkdir(parents=True, exist_ok=True)
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
_add_defender_exclusion(exe)
log.info("Downloading GOST %s", GOST_RELEASE_ZIP)
with httpx.Client(timeout=120.0, follow_redirects=True) as c:
r = c.get(GOST_RELEASE_ZIP)
r.raise_for_status()
data = r.content
if len(data) < 64 or data[:2] != b"PK":
raise RuntimeError("Downloaded GOST zip looks invalid (not a zip).")
with zipfile.ZipFile(io.BytesIO(data), "r") as z:
names = [n for n in z.namelist() if n.lower().endswith("gost.exe")]
if not names:
raise RuntimeError("gost.exe not found in release zip")
with z.open(names[0]) as src, open(exe, "wb") as dst:
shutil.copyfileobj(src, dst)
# Add exclusion again after write in case Defender scanned during extraction
_add_defender_exclusion(exe)
log.info("GOST installed at %s", exe)
return exe
def build_gost_cmd(gost: Path, listen_http: str, forwards: list[str]) -> list[str]:
args = [str(gost), "-L", f"http://{listen_http}"]
for f in forwards:
args.extend(["-F", f])
return args
def popen_no_window(args: list[str]) -> subprocess.Popen:
cr = getattr(subprocess, "CREATE_NO_WINDOW", 0)
return subprocess.Popen(
args,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=cr,
)
def terminate_process(proc: subprocess.Popen | None) -> None:
if proc is None:
return
if proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
import os
from pathlib import Path
def app_data_dir() -> Path:
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
d = Path(base) / "ProxyChainManager"
d.mkdir(parents=True, exist_ok=True)
return d
def gost_exe_path() -> Path:
return app_data_dir() / "gost.exe"

View File

@@ -0,0 +1,338 @@
from __future__ import annotations
import asyncio
import logging
import random
import threading
import time
from collections.abc import Callable
from typing import Any
from .config import Settings, load_settings, save_settings
from .fetcher import fetch_proxy_json, normalize_entries
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, terminate_process
from .sysproxy import clear_system_proxy, set_system_proxy
from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies
log = logging.getLogger(__name__)
Notify = Callable[[dict[str, Any]], None]
def _filter_by_mode(pool: list[str], mode: str) -> list[str]:
"""Filter pool by obfuscation 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
class ChainService:
"""Background rotating proxy chain using GOST."""
def __init__(self, notify: Notify) -> None:
self._notify = notify
self._stop = threading.Event()
self._force_rotate = threading.Event()
self._thread: threading.Thread | None = None
self._proc = None
self._settings = load_settings()
self._current_chain: list[str] = []
@property
def settings(self) -> Settings:
return self._settings
@property
def current_chain(self) -> list[str]:
return list(self._current_chain)
def update_settings(self, s: Settings) -> None:
self._settings = s
save_settings(s)
def start(self) -> None:
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.start()
def stop(self) -> None:
self._stop.set()
terminate_process(self._proc)
self._proc = None
if self._thread:
self._thread.join(timeout=15)
self._teardown_network()
self._notify({"type": "state", "running": False})
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."})
if is_admin() and self._settings.kill_switch_enabled:
ok, msg = fw_disengage()
self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": False})
def _run_thread(self) -> None:
try:
asyncio.run(self._async_main())
except Exception:
log.exception("service thread failed")
self._notify({"type": "log", "text": "Fatal error in service thread (see log)."})
finally:
terminate_process(self._proc)
self._proc = None
self._teardown_network()
self._notify({"type": "state", "running": False})
async def _async_main(self) -> None:
self._notify({"type": "state", "running": True})
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."})
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..."})
gost.unlink(missing_ok=True)
try:
gost = ensure_gost()
except Exception as e:
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
return
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}"})
else:
self._notify({"type": "log", "text": "Could not determine real IP — exit IP comparison disabled."})
# Engage firewall kill-switch if enabled
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": "firewall", "engaged": False})
else:
self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
self._notify({"type": "firewall", "engaged": False})
last_full = 0.0
pool: list[str] = []
while not self._stop.is_set():
now = time.monotonic()
need_refresh = (
not 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:
chain = list(self._settings.pinned_chain)
await self._run_chain(gost, chain, real_ip)
if self._stop.is_set():
break
continue
if need_refresh:
self._notify({"type": "phase", "phase": "fetch"})
pool = await self._build_pool()
last_full = time.monotonic()
self._notify({"type": "pool", "count": len(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)
last_full = 0.0
continue
chain = self._pick_chain(filtered)
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."""
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)})
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"
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
self._notify({"type": "log", "text": f"GOST exited immediately: {err_msg}"})
return
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),
)
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."})
terminate_process(self._proc)
self._proc = None
return
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."})
terminate_process(self._proc)
self._proc = None
return
self._notify({"type": "hops", "hops": chain, "status": "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,
self._settings.proxy_bypass,
)
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"):
break
if 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),
)
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."})
break
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
terminate_process(self._proc)
self._proc = None
async def _wait_health_interval(self) -> str | None:
total = float(self._settings.health_check_seconds)
end = time.monotonic() + total
while time.monotonic() < end:
if self._stop.is_set():
return "stop"
if self._force_rotate.is_set():
self._force_rotate.clear()
self._notify({"type": "log", "text": "Manual rotate."})
return "rotate"
await asyncio.sleep(0.2)
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)
@staticmethod
def _short(url: str) -> str:
return (
url.replace("http://", "")
.replace("socks5://", "s5://")
.replace("socks4://", "s4://")
.replace("https://", "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

View File

@@ -0,0 +1,60 @@
"""Set / clear the Windows system-wide HTTP proxy via the registry + WinINet broadcast."""
from __future__ import annotations
import ctypes
import logging
import winreg
log = logging.getLogger(__name__)
_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
_SETTINGS_CHANGED = 39
_REFRESH = 37
def _broadcast() -> None:
"""Tell running apps (browsers, etc.) that proxy settings changed."""
try:
inet = ctypes.windll.wininet # type: ignore[attr-defined]
inet.InternetSetOptionW(0, _SETTINGS_CHANGED, 0, 0)
inet.InternetSetOptionW(0, _REFRESH, 0, 0)
except Exception:
pass
def set_system_proxy(host: str, port: int, bypass: str = "localhost;127.*;10.*;192.168.*;<local>") -> None:
proxy = f"{host}:{port}"
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1)
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy)
winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, bypass)
_broadcast()
log.info("System proxy set to %s (bypass: %s)", proxy, bypass)
except OSError:
log.exception("Failed to set system proxy")
def clear_system_proxy() -> None:
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 0)
_broadcast()
log.info("System proxy cleared")
except OSError:
log.exception("Failed to clear system proxy")
def is_system_proxy_set() -> bool:
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, "ProxyEnable")
return val == 1
except OSError:
return False

View File

@@ -0,0 +1,89 @@
"""System-tray icon: green = healthy chain, red = broken/stopped, yellow = connecting."""
from __future__ import annotations
import threading
from typing import Any, Callable
import pystray
from PIL import Image, ImageDraw
def _circle_icon(color: str, size: int = 64) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
pad = 4
draw.ellipse([pad, pad, size - pad, size - pad], fill=color)
return img
ICONS = {
"green": _circle_icon("#00e676"),
"red": _circle_icon("#ff1744"),
"yellow": _circle_icon("#ffab00"),
"gray": _circle_icon("#6c757d"),
}
TIPS = {
"green": "Proxy Chain: healthy",
"red": "Proxy Chain: broken / stopped",
"yellow": "Proxy Chain: connecting…",
"gray": "Proxy Chain: idle",
}
class TrayIcon:
def __init__(
self,
on_show: Callable[[], None],
on_quit: Callable[[], None],
on_rotate: Callable[[], None],
) -> None:
self._on_show = on_show
self._on_quit = on_quit
self._on_rotate = on_rotate
self._icon: pystray.Icon | None = None
self._thread: threading.Thread | None = None
self._state = "gray"
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
menu = pystray.Menu(
pystray.MenuItem("Show", self._show, default=True),
pystray.MenuItem("Rotate now", self._rotate),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", self._quit),
)
self._icon = pystray.Icon(
"ProxyChainManager",
icon=ICONS[self._state],
title=TIPS[self._state],
menu=menu,
)
self._thread = threading.Thread(target=self._icon.run, daemon=True)
self._thread.start()
def stop(self) -> None:
if self._icon:
try:
self._icon.stop()
except Exception:
pass
def set_state(self, state: str) -> None:
"""state: 'green', 'red', 'yellow', 'gray'."""
if state not in ICONS:
state = "gray"
self._state = state
if self._icon:
self._icon.icon = ICONS[state]
self._icon.title = TIPS[state]
def _show(self, icon: Any = None, item: Any = None) -> None:
self._on_show()
def _rotate(self, icon: Any = None, item: Any = None) -> None:
self._on_rotate()
def _quit(self, icon: Any = None, item: Any = None) -> None:
self._on_quit()

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
import asyncio
import logging
from typing import Callable
import httpx
log = logging.getLogger(__name__)
async def validate_proxies(
proxy_urls: list[str],
check_url: str,
concurrency: int,
timeout_seconds: float,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
sem = asyncio.Semaphore(max(1, concurrency))
ok: list[str] = []
lock = asyncio.Lock()
total = len(proxy_urls)
done = 0
async def one(u: str) -> None:
nonlocal done
async with sem:
good = await _check_one(u, check_url, timeout_seconds)
async with lock:
done += 1
if good:
ok.append(u)
if on_progress:
on_progress(done, total)
await asyncio.gather(*(one(u) for u in proxy_urls))
return ok
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))
async with httpx.AsyncClient(proxy=proxy_url, timeout=t, verify=True, follow_redirects=True) as c:
r = await c.get(check_url)
return r.status_code == 200 and len(r.content) > 0
except Exception:
return False
async def check_chain_exit_ip(
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."""
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
try:
return json.loads(body).get("ip") or json.loads(body).get("origin")
except Exception:
return None
# plain text mode
if body and len(body) < 50:
return body
return None
except Exception:
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)."""
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
try:
return json.loads(body).get("ip") or json.loads(body).get("origin")
except Exception:
return None
if body and len(body) < 50:
return body
return None
except Exception:
return None

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from .paths import app_data_dir
TASK_NAME = "ProxyChainManagerAutoStart"
def _write_launcher_cmd() -> Path:
"""Create a stable launcher script so Task Scheduler does not need fragile quoting."""
if getattr(sys, "frozen", False):
exe = Path(sys.executable).resolve()
body = f'@echo off\r\nstart "" /B "{exe}"\r\n'
else:
py = Path(sys.executable).resolve()
if py.name.lower() == "python.exe":
cand = py.with_name("pythonw.exe")
if cand.is_file():
py = cand
root = Path(__file__).resolve().parent.parent
run_py = (root / "run.py").resolve()
body = f'@echo off\r\n"{py}" "{run_py}"\r\n'
p = app_data_dir() / "launch_proxy_chain_manager.cmd"
p.write_text(body, encoding="utf-8")
return p
def install_logon_task() -> tuple[bool, str]:
launcher = _write_launcher_cmd()
tr = str(launcher)
try:
r = subprocess.run(
[
"schtasks",
"/Create",
"/F",
"/TN",
TASK_NAME,
"/TR",
tr,
"/SC",
"ONLOGON",
"/RL",
"LIMITED",
],
capture_output=True,
text=True,
timeout=60,
)
out = (r.stdout or "") + (r.stderr or "")
return r.returncode == 0, out.strip() or ("OK" if r.returncode == 0 else "Failed")
except Exception as e:
return False, str(e)
def uninstall_logon_task() -> tuple[bool, str]:
try:
r = subprocess.run(
["schtasks", "/Delete", "/F", "/TN", TASK_NAME],
capture_output=True,
text=True,
timeout=60,
)
out = (r.stdout or "") + (r.stderr or "")
return r.returncode == 0, out.strip() or ("OK" if r.returncode == 0 else "Failed")
except Exception as e:
return False, str(e)
def task_exists() -> bool:
try:
r = subprocess.run(
["schtasks", "/Query", "/TN", TASK_NAME],
capture_output=True,
text=True,
timeout=30,
)
return r.returncode == 0
except Exception:
return False

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
customtkinter>=5.2.0
httpx[socks]>=0.27.0
pystray>=0.19.0
pillow>=10.0.0

6
run.py Normal file
View File

@@ -0,0 +1,6 @@
"""Launcher for development (pythonw run.py) so scheduled task can find a stable path."""
from proxy_chain_manager.app import main
if __name__ == "__main__":
main()