v2: tabs, chain builder, obfuscation modes, hop slider, Defender exclusion, firewall kept
Made-with: Cursor
This commit is contained in:
46
ProxyChainManager.spec
Normal file
46
ProxyChainManager.spec
Normal 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
129
README.md
Normal 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** — 2–8 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** — 2–8 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
|
||||||
4579
build/ProxyChainManager/Analysis-00.toc
Normal file
4579
build/ProxyChainManager/Analysis-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
2936
build/ProxyChainManager/EXE-00.toc
Normal file
2936
build/ProxyChainManager/EXE-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
2913
build/ProxyChainManager/PKG-00.toc
Normal file
2913
build/ProxyChainManager/PKG-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
BIN
build/ProxyChainManager/PYZ-00.pyz
Normal file
BIN
build/ProxyChainManager/PYZ-00.pyz
Normal file
Binary file not shown.
1195
build/ProxyChainManager/PYZ-00.toc
Normal file
1195
build/ProxyChainManager/PYZ-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
BIN
build/ProxyChainManager/ProxyChainManager.pkg
Normal file
BIN
build/ProxyChainManager/ProxyChainManager.pkg
Normal file
Binary file not shown.
BIN
build/ProxyChainManager/base_library.zip
Normal file
BIN
build/ProxyChainManager/base_library.zip
Normal file
Binary file not shown.
BIN
build/ProxyChainManager/localpycs/pyimod01_archive.pyc
Normal file
BIN
build/ProxyChainManager/localpycs/pyimod01_archive.pyc
Normal file
Binary file not shown.
BIN
build/ProxyChainManager/localpycs/pyimod02_importers.pyc
Normal file
BIN
build/ProxyChainManager/localpycs/pyimod02_importers.pyc
Normal file
Binary file not shown.
BIN
build/ProxyChainManager/localpycs/pyimod03_ctypes.pyc
Normal file
BIN
build/ProxyChainManager/localpycs/pyimod03_ctypes.pyc
Normal file
Binary file not shown.
BIN
build/ProxyChainManager/localpycs/pyimod04_pywin32.pyc
Normal file
BIN
build/ProxyChainManager/localpycs/pyimod04_pywin32.pyc
Normal file
Binary file not shown.
BIN
build/ProxyChainManager/localpycs/struct.pyc
Normal file
BIN
build/ProxyChainManager/localpycs/struct.pyc
Normal file
Binary file not shown.
88
build/ProxyChainManager/warn-ProxyChainManager.txt
Normal file
88
build/ProxyChainManager/warn-ProxyChainManager.txt
Normal 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)
|
||||||
20376
build/ProxyChainManager/xref-ProxyChainManager.html
Normal file
20376
build/ProxyChainManager/xref-ProxyChainManager.html
Normal file
File diff suppressed because it is too large
Load Diff
21
build_exe.bat
Normal file
21
build_exe.bat
Normal 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
BIN
dist/ProxyChainManager.exe
vendored
Normal file
Binary file not shown.
3
proxy_chain_manager/__init__.py
Normal file
3
proxy_chain_manager/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Rotating proxy chain manager for Windows (GOST backend)."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
4
proxy_chain_manager/__main__.py
Normal file
4
proxy_chain_manager/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
proxy_chain_manager/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/app.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/app.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/config.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/fetcher.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/fetcher.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/firewall.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/firewall.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/gost_util.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/gost_util.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/paths.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/paths.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/service.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/service.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/sysproxy.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/sysproxy.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/tray.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/tray.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/validator.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/validator.cpython-311.pyc
Normal file
Binary file not shown.
BIN
proxy_chain_manager/__pycache__/windows_task.cpython-311.pyc
Normal file
BIN
proxy_chain_manager/__pycache__/windows_task.cpython-311.pyc
Normal file
Binary file not shown.
664
proxy_chain_manager/app.py
Normal file
664
proxy_chain_manager/app.py
Normal 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()
|
||||||
103
proxy_chain_manager/config.py
Normal file
103
proxy_chain_manager/config.py
Normal 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")
|
||||||
36
proxy_chain_manager/fetcher.py
Normal file
36
proxy_chain_manager/fetcher.py
Normal 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
|
||||||
175
proxy_chain_manager/firewall.py
Normal file
175
proxy_chain_manager/firewall.py
Normal 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 "")
|
||||||
93
proxy_chain_manager/gost_util.py
Normal file
93
proxy_chain_manager/gost_util.py
Normal 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
|
||||||
15
proxy_chain_manager/paths.py
Normal file
15
proxy_chain_manager/paths.py
Normal 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"
|
||||||
338
proxy_chain_manager/service.py
Normal file
338
proxy_chain_manager/service.py
Normal 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
|
||||||
60
proxy_chain_manager/sysproxy.py
Normal file
60
proxy_chain_manager/sysproxy.py
Normal 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
|
||||||
89
proxy_chain_manager/tray.py
Normal file
89
proxy_chain_manager/tray.py
Normal 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()
|
||||||
96
proxy_chain_manager/validator.py
Normal file
96
proxy_chain_manager/validator.py
Normal 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
|
||||||
84
proxy_chain_manager/windows_task.py
Normal file
84
proxy_chain_manager/windows_task.py
Normal 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
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
customtkinter>=5.2.0
|
||||||
|
httpx[socks]>=0.27.0
|
||||||
|
pystray>=0.19.0
|
||||||
|
pillow>=10.0.0
|
||||||
Reference in New Issue
Block a user