Build comprehensive privacy suite and hardened browser controls.

Adds VPN-aware leak handling, chain testing UX improvements, hardened Firefox launch/profile management, privacy/device hardening modules, and tray/status upgrades so the app is production-ready as the new baseline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Indiana Holmes
2026-05-16 13:50:05 -07:00
parent 9439037cac
commit d4296763ee
17 changed files with 2158 additions and 238 deletions

View File

@@ -9,7 +9,7 @@ datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis( a = Analysis(
['run.py'], ['C:\\Users\\india\\Desktop\\proxy god\\proxy-god\\run.py'],
pathex=[], pathex=[],
binaries=binaries, binaries=binaries,
datas=datas, datas=datas,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import logging
import shutil
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from .browser_profile import FirefoxHardening, ensure_firefox_profile
from .paths import app_data_dir
log = logging.getLogger(__name__)
@dataclass
class BrowserConfig:
firefox_path: str = ""
profile_dir: str = ""
clear_on_close: bool = True
disposable_profile: bool = False
kill_on_chain_drop: bool = True
auto_relaunch: bool = False
lock_managed_profile: bool = True
force_proxy: bool = True
disable_webrtc: bool = True
resist_fingerprinting: bool = True
disable_telemetry: bool = True
first_party_isolation: bool = True
strict_tracking_protection: bool = True
timezone_utc: bool = True
def default_firefox_path() -> str:
candidates = [
Path(r"C:\Program Files\Mozilla Firefox\firefox.exe"),
Path(r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe"),
]
for c in candidates:
if c.is_file():
return str(c)
return ""
def default_profile_dir() -> str:
return str(app_data_dir() / "browser_profiles" / "firefox_hardened")
class BrowserSession:
def __init__(self) -> None:
self._proc: subprocess.Popen[str] | None = None
self._profile_path: Path | None = None
def is_running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def pid(self) -> int | None:
if self._proc is None:
return None
return self._proc.pid
def launch(
self,
cfg: BrowserConfig,
proxy_host: str,
proxy_port: int,
) -> tuple[bool, str]:
raw_exe = (cfg.firefox_path or default_firefox_path()).strip().strip('"').strip("'")
exe = Path(raw_exe)
if not exe.is_file():
return False, f"Firefox executable not found: {raw_exe or '(empty path)'}"
raw_profile = (cfg.profile_dir or default_profile_dir()).strip().strip('"').strip("'")
profile_dir = Path(raw_profile)
hard = FirefoxHardening(
force_proxy=cfg.force_proxy,
disable_webrtc=cfg.disable_webrtc,
resist_fingerprinting=cfg.resist_fingerprinting,
disable_telemetry=cfg.disable_telemetry,
first_party_isolation=cfg.first_party_isolation,
strict_tracking_protection=cfg.strict_tracking_protection,
clear_on_shutdown=cfg.clear_on_close,
timezone_utc=cfg.timezone_utc,
)
if cfg.lock_managed_profile:
ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard)
if self.is_running():
self.stop()
if cfg.lock_managed_profile:
args = [str(exe), "-no-remote", "-profile", str(profile_dir)]
else:
args = [str(exe)]
try:
self._proc = subprocess.Popen(
args,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
)
# Smoke-check: catch immediate startup crashes and report clearly.
time.sleep(0.4)
rc = self._proc.poll()
if rc is not None:
self._proc = None
return False, f"Firefox exited immediately (code {rc}). Check path/profile."
self._profile_path = profile_dir if cfg.lock_managed_profile else None
log.info("Launched hardened Firefox pid=%s profile=%s", self._proc.pid, profile_dir)
if cfg.lock_managed_profile:
return True, f"Hardened Firefox started (pid {self._proc.pid})."
return True, f"Firefox started (unlocked mode, pid {self._proc.pid})."
except Exception as e:
self._proc = None
return False, f"Launch failed: {e!s}"
def stop(self, dispose: bool = False) -> tuple[bool, str]:
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
self._proc.wait(timeout=8)
except Exception:
try:
self._proc.kill()
except Exception:
pass
finally:
self._proc = None
if dispose and self._profile_path and self._profile_path.exists():
try:
shutil.rmtree(self._profile_path, ignore_errors=True)
except Exception:
pass
return True, "Browser stopped."

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
@dataclass
class FirefoxHardening:
force_proxy: bool = True
disable_webrtc: bool = True
resist_fingerprinting: bool = True
disable_telemetry: bool = True
first_party_isolation: bool = True
strict_tracking_protection: bool = True
clear_on_shutdown: bool = True
timezone_utc: bool = True
def _bool(v: bool) -> str:
return "true" if v else "false"
def build_user_js(
proxy_host: str,
proxy_port: int,
hard: FirefoxHardening,
) -> str:
lines: list[str] = [
"// Managed by Proxy God. Changes are overwritten on next launch.",
'user_pref("app.normandy.enabled", false);',
'user_pref("app.shield.optoutstudies.enabled", false);',
'user_pref("browser.newtabpage.activity-stream.feeds.telemetry", false);',
'user_pref("browser.newtabpage.activity-stream.telemetry", false);',
'user_pref("browser.ping-centre.telemetry", false);',
'user_pref("toolkit.telemetry.archive.enabled", false);',
'user_pref("toolkit.telemetry.bhrPing.enabled", false);',
'user_pref("toolkit.telemetry.enabled", false);',
'user_pref("toolkit.telemetry.firstShutdownPing.enabled", false);',
'user_pref("toolkit.telemetry.hybridContent.enabled", false);',
'user_pref("toolkit.telemetry.newProfilePing.enabled", false);',
'user_pref("toolkit.telemetry.shutdownPingSender.enabled", false);',
'user_pref("toolkit.telemetry.unified", false);',
'user_pref("datareporting.healthreport.uploadEnabled", false);',
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
'user_pref("network.trr.mode", 5);',
'user_pref("network.captive-portal-service.enabled", false);',
'user_pref("geo.enabled", false);',
'user_pref("media.peerconnection.enabled", false);',
'user_pref("media.navigator.enabled", false);',
'user_pref("dom.battery.enabled", false);',
'user_pref("dom.gamepad.enabled", false);',
'user_pref("dom.netinfo.enabled", false);',
'user_pref("webgl.disabled", false);',
'user_pref("webgl.enable-debug-renderer-info", false);',
]
if hard.force_proxy:
lines.extend(
[
'user_pref("network.proxy.type", 1);',
f'user_pref("network.proxy.http", "{proxy_host}");',
f'user_pref("network.proxy.http_port", {int(proxy_port)});',
f'user_pref("network.proxy.ssl", "{proxy_host}");',
f'user_pref("network.proxy.ssl_port", {int(proxy_port)});',
f'user_pref("network.proxy.socks", "{proxy_host}");',
f'user_pref("network.proxy.socks_port", {int(proxy_port)});',
'user_pref("network.proxy.socks_version", 5);',
'user_pref("network.proxy.socks_remote_dns", true);',
'user_pref("network.proxy.no_proxies_on", "");',
]
)
if hard.disable_webrtc:
lines.extend(
[
'user_pref("media.peerconnection.enabled", false);',
'user_pref("media.peerconnection.ice.default_address_only", true);',
'user_pref("media.peerconnection.ice.no_host", true);',
]
)
if hard.resist_fingerprinting:
lines.extend(
[
'user_pref("privacy.resistFingerprinting", true);',
'user_pref("privacy.resistFingerprinting.letterboxing", true);',
'user_pref("privacy.window.maxInnerWidth", 1600);',
'user_pref("privacy.window.maxInnerHeight", 900);',
]
)
if hard.first_party_isolation:
lines.append('user_pref("privacy.firstparty.isolate", true);')
if hard.disable_telemetry:
lines.append('user_pref("browser.send_pings", false);')
if hard.strict_tracking_protection:
lines.extend(
[
'user_pref("privacy.trackingprotection.enabled", true);',
'user_pref("privacy.trackingprotection.pbmode.enabled", true);',
]
)
if hard.clear_on_shutdown:
lines.extend(
[
'user_pref("privacy.sanitize.sanitizeOnShutdown", true);',
'user_pref("privacy.clearOnShutdown.history", true);',
'user_pref("privacy.clearOnShutdown.cookies", true);',
'user_pref("privacy.clearOnShutdown.cache", true);',
'user_pref("privacy.clearOnShutdown.downloads", true);',
'user_pref("privacy.clearOnShutdown.formdata", true);',
'user_pref("privacy.clearOnShutdown.sessions", true);',
]
)
if hard.timezone_utc:
lines.append('user_pref("privacy.resistFingerprinting.reduceTimerPrecision", true);')
lines.append("")
return "\n".join(lines)
def build_prefs_js_marker() -> str:
return (
"// Proxy God managed profile\n"
'// Do not edit manually; user.js is rewritten on launch.\n'
)
def ensure_firefox_profile(
profile_dir: Path,
proxy_host: str,
proxy_port: int,
hard: FirefoxHardening,
) -> None:
profile_dir.mkdir(parents=True, exist_ok=True)
(profile_dir / "user.js").write_text(
build_user_js(proxy_host, proxy_port, hard),
encoding="utf-8",
)
(profile_dir / "prefs.js").write_text(build_prefs_js_marker(), encoding="utf-8")

View File

@@ -115,7 +115,7 @@ class Settings:
# ── chain ───────────────────────────────────────────────────────────── # ── chain ─────────────────────────────────────────────────────────────
chain_length: int = 3 # number of hops (2-8) chain_length: int = 3 # number of hops (2-8)
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
use_pinned_chain: bool = False # use manually ordered chain use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
# Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins) # Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins)
manual_exit_proxy: str = "" manual_exit_proxy: str = ""
@@ -135,6 +135,29 @@ class Settings:
kill_switch_enabled: bool = True # engage Windows Firewall kill-switch when running kill_switch_enabled: bool = True # engage Windows Firewall kill-switch when running
proxy_bypass: str = "localhost;127.*;10.*;192.168.*;<local>" proxy_bypass: str = "localhost;127.*;10.*;192.168.*;<local>"
# ── privacy / device hardening (Privacy tab) ───────────────────────────
mac_spoof_enabled: bool = False # randomize NIC MAC while chain runs (Admin)
spoof_hostname_enabled: bool = False # temporary computer name while chain runs (Admin)
flush_dns_on_rotate: bool = True # ipconfig /flushdns on each rotation
disable_ipv6_while_active: bool = False # disable IPv6 bindings while chain runs (Admin)
harden_webrtc_enabled: bool = False # Chrome/Edge WebRTC policy (Admin)
# ── hardened browser ────────────────────────────────────────────────────
firefox_path: str = ""
firefox_profile_dir: str = ""
browser_clear_on_close: bool = True
browser_disposable_profile: bool = False
browser_kill_on_chain_drop: bool = True
browser_auto_relaunch: bool = False
browser_lock_managed_profile: bool = True
browser_force_proxy: bool = True
browser_disable_webrtc: bool = True
browser_resist_fingerprinting: bool = True
browser_disable_telemetry: bool = True
browser_first_party_isolation: bool = True
browser_strict_tracking_protection: bool = True
browser_timezone_utc: bool = True
# ── sources ─────────────────────────────────────────────────────────── # ── sources ───────────────────────────────────────────────────────────
sources: list[str] = field( sources: list[str] = field(
default_factory=lambda: [ default_factory=lambda: [

View File

@@ -0,0 +1,112 @@
"""DNS leak checks and cache flush."""
from __future__ import annotations
import logging
import subprocess
from dataclasses import dataclass
import httpx
log = logging.getLogger(__name__)
_DOH_GOOGLE = "https://dns.google/resolve?name=whoami.dnsleaktest.com&type=A"
@dataclass
class DnsLeakResult:
ok: bool
system_resolvers: list[str]
message: str
doh_ip: str | None = None
def get_system_dns_servers() -> list[str]:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-DnsClientServerAddress -AddressFamily IPv4 | "
"Where-Object { $_.ServerAddresses } | "
"Select-Object -ExpandProperty ServerAddresses) -join ','"],
capture_output=True,
text=True,
timeout=12,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
raw = (r.stdout or "").strip()
if not raw:
return []
return [x.strip() for x in raw.replace(";", ",").split(",") if x.strip()]
except Exception:
return []
def flush_dns_cache() -> tuple[bool, str]:
try:
r = subprocess.run(
["ipconfig", "/flushdns"],
capture_output=True,
text=True,
timeout=15,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
msg = (r.stdout or r.stderr or "").strip().splitlines()[-1] if r.returncode == 0 else "flush failed"
return r.returncode == 0, msg
except Exception as e:
return False, str(e)
def check_dns_leak_hint(local_proxy: str | None = None) -> DnsLeakResult:
"""Heuristic: list configured DNS servers; note if any are public ISP resolvers.
Full DNS leak testing needs OS-level routing; this flags obvious misconfig.
"""
resolvers = get_system_dns_servers()
private_prefixes = ("127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.", "0.0.0.0")
public = [r for r in resolvers if not any(r.startswith(p) for p in private_prefixes)]
doh_ip: str | None = None
try:
with httpx.Client(timeout=8.0, verify=True) as c:
r = c.get(_DOH_GOOGLE)
if r.status_code == 200:
data = r.json()
answers = data.get("Answer") or []
if answers:
doh_ip = str(answers[0].get("data", ""))
except Exception:
pass
if not resolvers:
return DnsLeakResult(
ok=True,
system_resolvers=[],
message="No IPv4 DNS servers reported (DHCP may assign later).",
doh_ip=doh_ip,
)
if public and local_proxy:
return DnsLeakResult(
ok=False,
system_resolvers=resolvers,
message=(
f"DNS may bypass proxy chain: public resolvers {', '.join(public)}. "
"Use kill-switch + VPN, or set DNS to localhost when hardened."
),
doh_ip=doh_ip,
)
if public and not local_proxy:
return DnsLeakResult(
ok=False,
system_resolvers=resolvers,
message=f"Public DNS resolvers active: {', '.join(public)}",
doh_ip=doh_ip,
)
return DnsLeakResult(
ok=True,
system_resolvers=resolvers,
message=f"DNS servers: {', '.join(resolvers)}",
doh_ip=doh_ip,
)

View File

@@ -0,0 +1,221 @@
"""Device fingerprint audit and OS-level hardening helpers."""
from __future__ import annotations
import logging
import random
import re
import string
import subprocess
import winreg
from dataclasses import dataclass, field
from .firewall import is_admin
from .mac_spoof import list_nics
log = logging.getLogger(__name__)
_WEBRTC_CHROME = r"SOFTWARE\Policies\Google\Chrome"
_WEBRTC_EDGE = r"SOFTWARE\Policies\Microsoft\Edge"
_WEBRTC_VALUE = "DefaultWebRtcIpHandlingPolicy"
_WEBRTC_DISABLE = 2 # disable_non_proxied_udp
@dataclass
class FingerprintAudit:
lines: list[str] = field(default_factory=list)
hostname: str = ""
machine_guid: str = ""
username: str = ""
macs: list[str] = field(default_factory=list)
def _run_ps(script: str, timeout: float = 15.0) -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command", script],
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return (r.stdout or "").strip()
except Exception as e:
log.debug("fingerprint ps: %s", e)
return ""
def get_computer_name() -> str:
try:
import os
return os.environ.get("COMPUTERNAME", "") or ""
except Exception:
return ""
def get_machine_guid() -> str:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography",
) as key:
val, _ = winreg.QueryValueEx(key, "MachineGuid")
return str(val)
except OSError:
return ""
def random_hostname(prefix: str = "PC") -> str:
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=7))
return f"{prefix}-{suffix}"[:15]
def set_computer_name(name: str) -> tuple[bool, str]:
"""Set NetBIOS / computer name (Admin). Reboot may be required for all apps."""
if not is_admin():
return False, "Administrator required to change computer name."
name = re.sub(r"[^A-Za-z0-9\-]", "", name)[:15]
if len(name) < 1:
return False, "Invalid hostname."
code = subprocess.run(
["powershell", "-NoProfile", "-Command",
f'Rename-Computer -NewName "{name}" -Force -ErrorAction Stop'],
capture_output=True,
text=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).returncode
if code != 0:
# NetBIOS name via WMI (often works without full rename)
wmi = (
f'$n = Get-WmiObject Win32_ComputerSystem; '
f'$r = $n.Rename("{name}"); if ($r.ReturnValue -ne 0) {{ exit $r.ReturnValue }}'
)
code = subprocess.run(
["powershell", "-NoProfile", "-Command", wmi],
capture_output=True,
text=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).returncode
if code == 0:
return True, f"Computer name set to {name} (some apps need reconnect/reboot)."
return False, "Could not change computer name."
def disable_ipv6_on_adapters() -> tuple[list[str], list[str]]:
"""Disable IPv6 binding on up physical adapters. Returns (adapter names, log lines)."""
if not is_admin():
return [], ["IPv6 disable skipped (not Admin)."]
script = (
"Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
"ForEach-Object { $_.Name }"
)
names = [n.strip() for n in _run_ps(script).splitlines() if n.strip()]
logs: list[str] = []
changed: list[str] = []
skip = ("virtual", "vmware", "hyper-v", "loopback", "bluetooth")
for name in names:
if any(h in name.lower() for h in skip):
continue
cmd = (
f'Disable-NetAdapterBinding -Name "{name}" -ComponentID ms_tcpip6 -Confirm:$false '
f'-ErrorAction SilentlyContinue'
)
subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
changed.append(name)
logs.append(f"IPv6 disabled on {name}")
return changed, logs
def enable_ipv6_on_adapters(adapters: list[str]) -> list[str]:
if not is_admin() or not adapters:
return []
logs: list[str] = []
for name in adapters:
cmd = (
f'Enable-NetAdapterBinding -Name "{name}" -ComponentID ms_tcpip6 -Confirm:$false '
f'-ErrorAction SilentlyContinue'
)
subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
logs.append(f"IPv6 re-enabled on {name}")
return logs
def apply_webrtc_hardening(enable: bool) -> tuple[bool, str]:
"""Chrome/Edge: disable WebRTC non-proxied UDP (Admin, HKLM policies)."""
if not is_admin():
return False, "Administrator required for browser WebRTC policy."
paths = [_WEBRTC_CHROME, _WEBRTC_EDGE]
try:
for path in paths:
if enable:
try:
key = winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
)
except OSError:
continue
with key:
winreg.SetValueEx(key, _WEBRTC_VALUE, 0, winreg.REG_DWORD, _WEBRTC_DISABLE)
else:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
try:
winreg.DeleteValue(key, _WEBRTC_VALUE)
except OSError:
pass
except OSError:
pass
return True, (
"WebRTC hardened (Chrome/Edge: non-proxied UDP disabled)."
if enable
else "WebRTC policy removed."
)
except OSError as e:
return False, str(e)
def audit_device() -> FingerprintAudit:
"""Collect identifiers sites and trackers often fingerprint."""
import getpass
import os
host = get_computer_name()
guid = get_machine_guid()
user = getpass.getuser()
macs = [f"{n.name}: {n.mac}" for n in list_nics()]
lines = [
f"Computer name: {host or ''}",
f"Windows username: {user}",
f"MachineGuid: {guid[:8]}{guid[-4:]}" if len(guid) > 12 else f"MachineGuid: {guid or ''}",
f"Network adapters ({len(macs)}):",
]
lines.extend([f"{m}" for m in macs[:8]] or [" • (none detected)"])
if len(macs) > 8:
lines.append(f" … and {len(macs) - 8} more")
lines.append("")
lines.append("Browser fingerprint (Canvas/WebGL/fonts) is not changed by this app.")
lines.append("Use hardened browser profiles + proxy chain for web traffic.")
lines.append("WebRTC toggle here affects Chrome/Edge system policy only.")
return FingerprintAudit(
lines=lines,
hostname=host,
machine_guid=guid,
username=user,
macs=[n.mac for n in list_nics()],
)

View File

@@ -13,24 +13,18 @@ When disengaged:
from __future__ import annotations from __future__ import annotations
import ctypes import ctypes
import glob
import logging import logging
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from .paths import gost_exe_path from .paths import gost_exe_path
from .vpn_detect import expand_vpn_executables
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
RULE_PREFIX = "PCM_" 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: def is_admin() -> bool:
try: try:
@@ -99,13 +93,6 @@ def _resolve_self_exe() -> Path:
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]: def engage(gost_path: Path | None = None) -> tuple[bool, str]:
"""Activate kill-switch firewall. Returns (success, message).""" """Activate kill-switch firewall. Returns (success, message)."""
if not is_admin(): if not is_admin():
@@ -113,7 +100,7 @@ def engage(gost_path: Path | None = None) -> tuple[bool, str]:
gost = gost_path or gost_exe_path() gost = gost_path or gost_exe_path()
self_exe = _resolve_self_exe() self_exe = _resolve_self_exe()
nord_exes = _expand_nord_exes() vpn_exes = expand_vpn_executables()
_delete_rules() _delete_rules()
@@ -137,10 +124,10 @@ def engage(gost_path: Path | None = None) -> tuple[bool, str]:
_add_rule(f"Python_{sibling}", dir="out", action="allow", _add_rule(f"Python_{sibling}", dir="out", action="allow",
program=f'"{p}"', protocol="any") program=f'"{p}"', protocol="any")
# Allow all NordVPN executables # Allow VPN client executables (Nord, WireGuard, OpenVPN, etc.)
for i, npath in enumerate(nord_exes): for i, vpath in enumerate(vpn_exes):
_add_rule(f"Nord_{i}", dir="out", action="allow", _add_rule(f"VPN_{i}", dir="out", action="allow",
program=f'"{npath}"', protocol="any") program=f'"{vpath}"', protocol="any")
# Allow DHCP (or you lose your adapter) # Allow DHCP (or you lose your adapter)
_add_rule("DHCP", dir="out", action="allow", _add_rule("DHCP", dir="out", action="allow",
@@ -155,8 +142,8 @@ def engage(gost_path: Path | None = None) -> tuple[bool, str]:
# Set default outbound to BLOCK # Set default outbound to BLOCK
_set_outbound_policy("blockinbound,blockoutbound") _set_outbound_policy("blockinbound,blockoutbound")
log.info("Firewall kill-switch engaged. %d Nord exes whitelisted.", len(nord_exes)) log.info("Firewall kill-switch engaged. %d VPN exes whitelisted.", len(vpn_exes))
return True, f"Kill-switch ON. {len(nord_exes)} Nord processes whitelisted." return True, f"Kill-switch ON. {len(vpn_exes)} VPN client(s) whitelisted."
def disengage() -> tuple[bool, str]: def disengage() -> tuple[bool, str]:

View File

@@ -0,0 +1,41 @@
"""Fast parallel proxy checks for the GUI (per-hop callbacks)."""
from __future__ import annotations
import asyncio
from typing import Callable
from .validator import _check_one
# High concurrency for interactive chain tests
GUI_VALIDATE_CONCURRENCY = 48
async def validate_each_parallel(
urls: list[str],
check_url: str,
concurrency: int,
timeout_seconds: float,
on_result: Callable[[str, bool], None],
) -> None:
if not urls:
return
sem = asyncio.Semaphore(max(1, concurrency))
async def one(u: str) -> None:
async with sem:
ok = await _check_one(u, check_url, timeout_seconds)
on_result(u, ok)
await asyncio.gather(*(one(u) for u in urls))
def run_validate_each_sync(
urls: list[str],
check_url: str,
timeout_seconds: float,
on_result: Callable[[str, bool], None],
concurrency: int = GUI_VALIDATE_CONCURRENCY,
) -> None:
asyncio.run(
validate_each_parallel(urls, check_url, concurrency, timeout_seconds, on_result)
)

View File

@@ -0,0 +1,55 @@
"""VPN-aware chain leak detection."""
from __future__ import annotations
def is_same_subnet(ip_a: str | None, ip_b: str | None, prefix_len: int = 16) -> bool:
"""True if two IPv4 addresses share the same /prefix_len subnet."""
if not ip_a or not ip_b:
return False
try:
a_parts = [int(x) for x in ip_a.split(".")]
b_parts = [int(x) for x in ip_b.split(".")]
if len(a_parts) != 4 or len(b_parts) != 4:
return False
def to_int(parts: list[int]) -> int:
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
return (to_int(a_parts) & mask) == (to_int(b_parts) & mask)
except Exception:
return False
def is_chain_leak(exit_ip: str | None, real_ip: str | None, vpn_active: bool) -> bool:
"""True when the chain is not forwarding (traffic still looks like direct/VPN exit).
With VPN: compare /16 — VPN IPs rotate but stay in-provider ranges.
Without VPN: exact IP match only (avoid false positives on same ISP /16).
"""
if not exit_ip:
return True
if not real_ip:
return False
if exit_ip == real_ip:
return True
if vpn_active:
return is_same_subnet(exit_ip, real_ip)
return False
def leak_reason(exit_ip: str | None, real_ip: str | None, vpn_active: bool) -> str:
if not exit_ip:
return "exit IP unreachable"
if not real_ip:
return "unknown direct IP"
if exit_ip == real_ip:
if vpn_active:
return f"exit {exit_ip} equals VPN/direct IP (chain not forwarding)"
return f"exit {exit_ip} equals your real IP (no anonymization)"
if vpn_active and is_same_subnet(exit_ip, real_ip):
return (
f"exit {exit_ip} shares /16 with direct {real_ip} "
"(likely exiting via VPN tunnel, not proxy chain)"
)
return "ok"

View File

@@ -0,0 +1,113 @@
"""Randomize and restore NIC MAC addresses (Admin required)."""
from __future__ import annotations
import logging
import random
import re
import subprocess
from dataclasses import dataclass
from .firewall import is_admin
log = logging.getLogger(__name__)
_MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$")
@dataclass
class NicMac:
name: str
mac: str
description: str = ""
def _run(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
try:
r = subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as e:
return 1, "", str(e)
def list_nics() -> list[NicMac]:
"""Physical/up adapters with current MAC."""
script = (
"Get-NetAdapter | Where-Object { $_.Status -ne 'Disabled' } | "
"Select-Object Name, MacAddress, InterfaceDescription | "
"ConvertTo-Json -Compress"
)
code, out, _ = _run(["powershell", "-NoProfile", "-Command", script])
if code != 0 or not out.strip():
return []
import json
try:
data = json.loads(out)
except json.JSONDecodeError:
return []
rows = data if isinstance(data, list) else [data]
nics: list[NicMac] = []
for row in rows:
if not isinstance(row, dict):
continue
name = str(row.get("Name", "")).strip()
mac = str(row.get("MacAddress", "")).strip().replace("-", ":")
desc = str(row.get("InterfaceDescription", "")).strip()
if name and mac and mac != "00:00:00:00:00:00":
nics.append(NicMac(name=name, mac=mac, description=desc))
return nics
def random_mac() -> str:
"""Locally administered unicast MAC."""
b = [random.randint(0, 255) for _ in range(6)]
b[0] = (b[0] | 0x02) & 0xFE
return ":".join(f"{x:02X}" for x in b)
def set_mac(adapter: str, mac: str) -> tuple[bool, str]:
if not is_admin():
return False, "Administrator required to change MAC."
mac = mac.replace("-", ":").upper()
if not _MAC_RE.match(mac.replace(":", "-")):
return False, f"Invalid MAC: {mac}"
ps = (
f'$a = Get-NetAdapter -Name "{adapter}" -ErrorAction Stop; '
f'Set-NetAdapter -Name $a.Name -MacAddress "{mac}" -Confirm:$false'
)
code, _, err = _run(["powershell", "-NoProfile", "-Command", ps])
if code != 0:
return False, err or "Set-NetAdapter failed"
log.info("MAC set %s%s", adapter, mac)
return True, f"{adapter}{mac}"
def spoof_all_physical(snapshot: dict[str, str] | None = None) -> tuple[dict[str, str], list[str]]:
"""Randomize MAC on non-virtual adapters. Returns (original_map, log lines)."""
originals: dict[str, str] = dict(snapshot or {})
logs: list[str] = []
skip_hints = ("virtual", "vmware", "hyper-v", "loopback", "bluetooth", "wan miniport")
for nic in list_nics():
low = (nic.description + nic.name).lower()
if any(h in low for h in skip_hints):
continue
if nic.name not in originals:
originals[nic.name] = nic.mac
new_mac = random_mac()
ok, msg = set_mac(nic.name, new_mac)
logs.append(msg if ok else f"{nic.name}: {msg}")
return originals, logs
def restore_macs(originals: dict[str, str]) -> list[str]:
logs: list[str] = []
for name, mac in originals.items():
ok, msg = set_mac(name, mac)
logs.append(msg if ok else f"{name}: {msg}")
return logs

View File

@@ -0,0 +1,106 @@
"""Fetch proxy lists for UI picker and shared pool building."""
from __future__ import annotations
import asyncio
import logging
import random
from concurrent.futures import ThreadPoolExecutor
from typing import Callable
from .config import Settings
from .fetcher import fetch_proxy_json, normalize_entries
from .validator import validate_proxies
log = logging.getLogger(__name__)
_POOL_EXEC = ThreadPoolExecutor(max_workers=6, thread_name_prefix="pool_ops")
PICKER_DISPLAY_CAP = 200
class ProxySourceCache:
"""Cache full source lists; each UI pull shows a new random batch."""
def __init__(self) -> None:
self._full: list[str] = []
self.total_cached: int = 0
def clear(self) -> None:
self._full.clear()
self.total_cached = 0
@property
def loaded(self) -> bool:
return bool(self._full)
def load(self, sources: list[str], prefer_elite: bool,
on_status: Callable[[str], None] | None = None) -> int:
self._full = fetch_raw_proxies(sources, prefer_elite, on_status=on_status)
self.total_cached = len(self._full)
return self.total_cached
def random_batch(self, cap: int = PICKER_DISPLAY_CAP) -> list[str]:
if not self._full:
return []
k = min(cap, len(self._full))
return random.sample(self._full, k)
def fetch_raw_proxies(
sources: list[str],
prefer_elite: bool,
on_status: Callable[[str], None] | None = None,
) -> list[str]:
"""Blocking fetch from all JSON sources; deduped, not validated."""
if not sources:
sources = list(Settings().sources)
seen: set[str] = set()
out: list[str] = []
for url in sources:
if on_status:
on_status(f"Fetching {url[:70]}")
try:
rows = fetch_proxy_json(url, timeout=45.0)
entries = normalize_entries(rows, prefer_elite)
if on_status:
on_status(f"{len(entries)} proxies")
for u in entries:
if u not in seen:
seen.add(u)
out.append(u)
except Exception as e:
log.warning("fetch_raw_proxies %s: %s", url[:60], e)
if on_status:
on_status(f" → error: {e!s}")
return out
async def validate_proxy_subset(
urls: list[str],
settings: Settings,
target: int = 0,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
if not urls:
return []
sample = list(urls)
random.shuffle(sample)
if len(sample) > settings.max_candidates:
sample = sample[: settings.max_candidates]
tgt = target or settings.min_pool_size
return await validate_proxies(
sample,
settings.ip_check_url,
settings.validation_concurrency,
settings.validation_timeout_seconds,
on_progress=on_progress,
target=tgt,
)
def run_validate_sync(
urls: list[str],
settings: Settings,
target: int = 0,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
return asyncio.run(validate_proxy_subset(urls, settings, target=target, on_progress=on_progress))

View File

@@ -0,0 +1,201 @@
"""Modal dialog: random batch from cached sources, multi-select."""
from __future__ import annotations
import threading
from typing import Any, Callable
import customtkinter as ctk
from .config import Settings, redact_proxy_url
from .pool_ops import PICKER_DISPLAY_CAP, ProxySourceCache
BG = "#0d0d1a"
CARD = "#12122a"
PANEL = "#1a1a3e"
ACCENT = "#1e3a8a"
ACCENT2 = "#2563eb"
GREEN = "#00e676"
RED = "#ff1744"
YELLOW = "#ffab00"
DIM = "#4a5568"
TEXT = "#e2e8f0"
TEXT2 = "#94a3b8"
FONT = "Segoe UI"
_UI_CHUNK = 50
# One cache per app session — avoids re-downloading 800+ URLs every open
_SESSION_CACHE = ProxySourceCache()
def show_proxy_picker(
parent: Any,
settings: Settings,
prefer_elite: bool,
on_done: Callable[[list[str], str], None],
) -> None:
"""``on_done(urls, mode)`` where mode is ``append`` or ``replace``."""
win = ctk.CTkToplevel(parent)
win.title("Browse proxy lists")
win.geometry("700x500")
win.configure(fg_color=BG)
win.transient(parent)
win.grab_set()
status = ctk.CTkLabel(
win,
text=f"Pull loads sources once, then shows {PICKER_DISPLAY_CAP} random proxies per batch.",
font=(FONT, 11),
text_color=TEXT2,
)
status.pack(fill="x", padx=12, pady=(10, 4))
filter_var = ctk.StringVar(value="")
filt_row = ctk.CTkFrame(win, fg_color="transparent")
filt_row.pack(fill="x", padx=12, pady=4)
ctk.CTkLabel(filt_row, text="Filter:", font=(FONT, 10), text_color=TEXT2).pack(side="left")
ctk.CTkEntry(
filt_row, textvariable=filter_var, width=180, height=28,
fg_color=BG, border_color=ACCENT,
).pack(side="left", padx=6)
proto_var = ctk.StringVar(value="all")
for label, val in [("All", "all"), ("HTTP", "http"), ("SOCKS5", "socks5")]:
ctk.CTkRadioButton(
filt_row, text=label, variable=proto_var, value=val,
font=(FONT, 10), fg_color=ACCENT2, text_color=TEXT,
).pack(side="left", padx=4)
scroll = ctk.CTkScrollableFrame(win, fg_color=CARD, height=300,
scrollbar_button_color=ACCENT)
scroll.pack(fill="both", expand=True, padx=12, pady=6)
display_proxies: list[str] = []
check_vars: dict[str, ctk.BooleanVar] = {}
_build_gen = [0]
def _filtered_urls() -> list[str]:
q = filter_var.get().strip().lower()
pv = proto_var.get()
out: list[str] = []
for u in display_proxies:
if pv == "http" and not u.startswith("http://"):
continue
if pv == "socks5" and not u.startswith("socks5://"):
continue
if q and q not in u.lower():
continue
out.append(u)
return out
def _rebuild_list() -> None:
_build_gen[0] += 1
gen = _build_gen[0]
for w in scroll.winfo_children():
w.destroy()
urls = _filtered_urls()
if not urls:
status.configure(
text="No proxies match filter — pull a batch or change filter.",
text_color=TEXT2,
)
return
def _chunk(start: int) -> None:
if gen != _build_gen[0]:
return
end = min(start + _UI_CHUNK, len(urls))
for u in urls[start:end]:
if u not in check_vars:
check_vars[u] = ctk.BooleanVar(value=False)
fr = ctk.CTkFrame(scroll, fg_color=PANEL, corner_radius=4)
fr.pack(fill="x", pady=1)
ctk.CTkCheckBox(
fr, text=redact_proxy_url(u), variable=check_vars[u],
font=("Consolas", 10), fg_color=ACCENT2, text_color=TEXT,
).pack(anchor="w", padx=8, pady=2)
if end < len(urls):
win.after(1, lambda: _chunk(end))
else:
cached = _SESSION_CACHE.total_cached
status.configure(
text=f"Showing {len(urls)} of batch ({cached} cached in memory). Select → OK.",
text_color=GREEN,
)
_chunk(0)
def _select_all(on: bool) -> None:
for u in _filtered_urls():
if u in check_vars:
check_vars[u].set(on)
def _pull(force_reload: bool = False) -> None:
pull_btn.configure(state="disabled")
reload_btn.configure(state="disabled")
status.configure(text="Loading sources…", text_color=YELLOW)
def work() -> None:
def stat(msg: str) -> None:
win.after(0, lambda m=msg: status.configure(text=m, text_color=TEXT2))
if force_reload:
_SESSION_CACHE.clear()
if not _SESSION_CACHE.loaded:
_SESSION_CACHE.load(list(settings.sources), prefer_elite, on_status=stat)
batch = _SESSION_CACHE.random_batch(PICKER_DISPLAY_CAP)
def finish() -> None:
nonlocal display_proxies
display_proxies = batch
check_vars.clear()
for u in batch:
check_vars[u] = ctk.BooleanVar(value=False)
pull_btn.configure(state="normal")
reload_btn.configure(state="normal")
_rebuild_list()
win.after(0, finish)
threading.Thread(target=work, daemon=True).start()
def _selected() -> list[str]:
return [u for u, v in check_vars.items() if v.get()]
def _ok(mode: str) -> None:
sel = _selected()
if not sel:
status.configure(text="Select at least one proxy.", text_color=RED)
return
win.grab_release()
win.destroy()
on_done(sel, mode)
btn_row = ctk.CTkFrame(win, fg_color="transparent")
btn_row.pack(fill="x", padx=12, pady=(4, 12))
pull_btn = ctk.CTkButton(
btn_row, text="↻ Random batch", command=lambda: _pull(False),
width=120, fg_color=ACCENT, hover_color=ACCENT2,
)
pull_btn.pack(side="left", padx=2)
reload_btn = ctk.CTkButton(
btn_row, text="Reload sources", command=lambda: _pull(True),
width=110, fg_color=DIM, hover_color=ACCENT,
)
reload_btn.pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Select all", command=lambda: _select_all(True),
width=72, fg_color=DIM).pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Clear", command=lambda: _select_all(False),
width=52, fg_color=DIM).pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Cancel",
command=lambda: (win.grab_release(), win.destroy()),
width=64, fg_color="#7f1d1d").pack(side="right", padx=2)
ctk.CTkButton(btn_row, text="Add to chain", command=lambda: _ok("append"),
width=100, fg_color=GREEN, hover_color=ACCENT2).pack(side="right", padx=2)
ctk.CTkButton(btn_row, text="Replace chain", command=lambda: _ok("replace"),
width=100, fg_color=ACCENT2).pack(side="right", padx=2)
filter_var.trace_add("write", lambda *_: _rebuild_list())
proto_var.trace_add("write", lambda *_: _rebuild_list())
_pull(False)

View File

@@ -16,11 +16,23 @@ from .config import (
redact_proxy_url, redact_proxy_url,
save_settings, save_settings,
) )
from .dns_leak import flush_dns_cache
from .fetcher import fetch_proxy_json, normalize_entries from .fetcher import fetch_proxy_json, normalize_entries
from .fingerprint import (
apply_webrtc_hardening,
disable_ipv6_on_adapters,
enable_ipv6_on_adapters,
get_computer_name,
random_hostname,
set_computer_name,
)
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin 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, read_gost_log_tail, terminate_process from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, read_gost_log_tail, terminate_process
from .leak_detect import is_chain_leak, leak_reason
from .mac_spoof import restore_macs, spoof_all_physical
from .sysproxy import clear_system_proxy, set_system_proxy from .sysproxy import clear_system_proxy, set_system_proxy
from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies
from .vpn_detect import VpnStatus, detect_vpn
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -30,30 +42,8 @@ Notify = Callable[[dict[str, Any]], None]
_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher") _FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
def _is_same_network(ip_a: str | None, ip_b: str | None, prefix_len: int = 16) -> bool: # Back-compat for tests
"""True if two IPv4 addresses share the same /<prefix_len> subnet. from .leak_detect import is_same_subnet as _is_same_network # noqa: F401
With NordVPN (or any VPN), the VPN provider rotates IPs so an exact-match
comparison misses leaks where the chain exits through the VPN tunnel directly.
A /16 check catches same-ISP/same-VPN exit while still allowing genuine
unrelated proxies that happen to share a /24 with the VPN exit.
Returns False if either address is None or non-IPv4.
"""
if not ip_a or not ip_b:
return False
try:
a_parts = [int(x) for x in ip_a.split(".")]
b_parts = [int(x) for x in ip_b.split(".")]
if len(a_parts) != 4 or len(b_parts) != 4:
return False
def to_int(parts: list[int]) -> int:
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
return (to_int(a_parts) & mask) == (to_int(b_parts) & mask)
except Exception:
return False
class ChainService: class ChainService:
@@ -74,6 +64,12 @@ class ChainService:
# Per-session blacklist: proxies that crashed GOST immediately # Per-session blacklist: proxies that crashed GOST immediately
self._blacklist: set[str] = set() self._blacklist: set[str] = set()
self._vpn: VpnStatus = VpnStatus()
self._mac_originals: dict[str, str] = {}
self._hostname_original: str | None = None
self._ipv6_adapters: list[str] = []
self._webrtc_was_applied: bool = False
def _manual_exit_url(self) -> str | None: def _manual_exit_url(self) -> str | None:
u = normalize_proxy_url(self._settings.manual_exit_proxy) u = normalize_proxy_url(self._settings.manual_exit_proxy)
return u if u else None return u if u else None
@@ -116,11 +112,61 @@ class ChainService:
def _teardown_network(self) -> None: def _teardown_network(self) -> None:
clear_system_proxy() clear_system_proxy()
self._notify({"type": "log", "text": "System proxy cleared."}) self._notify({"type": "log", "text": "System proxy cleared."})
self._restore_privacy()
if is_admin() and self._settings.kill_switch_enabled: if is_admin() and self._settings.kill_switch_enabled:
ok, msg = fw_disengage() ok, msg = fw_disengage()
self._notify({"type": "log", "text": msg}) self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": False}) self._notify({"type": "firewall", "engaged": False})
def _apply_privacy(self) -> None:
s = self._settings
if s.mac_spoof_enabled and is_admin():
self._mac_originals, logs = spoof_all_physical(self._mac_originals or None)
for ln in logs:
self._notify({"type": "log", "text": f"MAC: {ln}"})
elif s.mac_spoof_enabled:
self._notify({"type": "log", "text": "MAC spoof enabled but not Admin — skipped."})
if s.spoof_hostname_enabled and is_admin():
self._hostname_original = get_computer_name()
new_name = random_hostname()
ok, msg = set_computer_name(new_name)
self._notify({"type": "log", "text": f"Hostname: {msg}"})
elif s.spoof_hostname_enabled:
self._notify({"type": "log", "text": "Hostname spoof enabled but not Admin — skipped."})
if s.disable_ipv6_while_active and is_admin():
self._ipv6_adapters, logs = disable_ipv6_on_adapters()
for ln in logs:
self._notify({"type": "log", "text": ln})
elif s.disable_ipv6_while_active:
self._notify({"type": "log", "text": "IPv6 disable enabled but not Admin — skipped."})
if s.harden_webrtc_enabled and is_admin():
ok, msg = apply_webrtc_hardening(True)
self._webrtc_was_applied = ok
self._notify({"type": "log", "text": msg})
elif s.harden_webrtc_enabled:
self._notify({"type": "log", "text": "WebRTC hardening enabled but not Admin — skipped."})
def _restore_privacy(self) -> None:
if self._mac_originals:
for ln in restore_macs(self._mac_originals):
self._notify({"type": "log", "text": f"MAC restore: {ln}"})
self._mac_originals.clear()
if self._hostname_original and is_admin():
ok, msg = set_computer_name(self._hostname_original)
self._notify({"type": "log", "text": f"Hostname restore: {msg}"})
self._hostname_original = None
if self._ipv6_adapters:
for ln in enable_ipv6_on_adapters(self._ipv6_adapters):
self._notify({"type": "log", "text": ln})
self._ipv6_adapters.clear()
if self._webrtc_was_applied and is_admin():
apply_webrtc_hardening(False)
self._webrtc_was_applied = False
self._notify({"type": "log", "text": "WebRTC policy restored."})
def _run_thread(self) -> None: def _run_thread(self) -> None:
try: try:
asyncio.run(self._async_main()) asyncio.run(self._async_main())
@@ -165,13 +211,34 @@ class ChainService:
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"}) self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
return return
# ── Real IP ────────────────────────────────────────────────────────── # ── VPN + direct IP ───────────────────────────────────────────────────
self._vpn = detect_vpn()
mode = "VPN-aware (/16)" if self._vpn.active else "strict (exact IP)"
self._notify({
"type": "vpn",
"active": self._vpn.active,
"label": self._vpn.label,
"adapter": self._vpn.adapter,
"leak_mode": mode,
})
self._notify({
"type": "log",
"text": (
f"VPN: {self._vpn.label}"
+ (f" ({self._vpn.adapter})" if self._vpn.adapter else "")
+ f" — leak check: {mode}"
),
})
real_ip = await get_direct_ip(self._settings.ip_check_url) real_ip = await get_direct_ip(self._settings.ip_check_url)
if real_ip: if real_ip:
self._notify({"type": "real_ip", "ip": real_ip}) self._notify({"type": "real_ip", "ip": real_ip})
self._notify({"type": "log", "text": f"Your real IP: {real_ip}"}) label = "direct/VPN IP" if self._vpn.active else "your real IP"
self._notify({"type": "log", "text": f"{label.capitalize()}: {real_ip}"})
else: else:
self._notify({"type": "log", "text": "Could not determine real IP — leak detection disabled."}) self._notify({"type": "log", "text": "Could not determine direct IP — leak detection disabled."})
self._apply_privacy()
# ── Firewall kill-switch ────────────────────────────────────────────── # ── Firewall kill-switch ──────────────────────────────────────────────
if self._settings.kill_switch_enabled: if self._settings.kill_switch_enabled:
@@ -301,6 +368,10 @@ class ChainService:
self._current_chain = list(chain) self._current_chain = list(chain)
self._notify({"type": "hops", "hops": chain, "status": "connecting"}) self._notify({"type": "hops", "hops": chain, "status": "connecting"})
self._notify({"type": "phase", "phase": "gost_start"}) self._notify({"type": "phase", "phase": "gost_start"})
if self._settings.flush_dns_on_rotate:
ok, msg = flush_dns_cache()
if ok:
log.debug("DNS cache flushed before chain run")
listen = self._settings.listen_addr() listen = self._settings.listen_addr()
cmd = build_gost_cmd(gost, listen, chain) cmd = build_gost_cmd(gost, listen, chain)
self._notify({ self._notify({
@@ -358,19 +429,11 @@ class ChainService:
self._proc = None self._proc = None
return False return False
if real_ip and _is_same_network(exit_ip, real_ip): if is_chain_leak(exit_ip, real_ip, self._vpn.active):
reason = leak_reason(exit_ip, real_ip, self._vpn.active)
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip}) self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({ self._notify({"type": "log", "text": f"Leak detected — {reason}. Rotating."})
"type": "log", log.warning("Chain leak: exit=%s real=%s vpn=%s%s", exit_ip, real_ip, self._vpn.active, reason)
"text": (
f"Leak detected! Exit={exit_ip} shares network with real IP {real_ip} "
f"(same /16 subnet — chain not forwarding, likely exiting via VPN directly). Rotating."
),
})
log.warning(
"Subnet leak: exit=%s real=%s — chain proxy not forwarding. Blacklisting chain.",
exit_ip, real_ip,
)
# Blacklist the whole chain so we don't reuse broken proxies # Blacklist the whole chain so we don't reuse broken proxies
fixed = self._manual_exit_url() fixed = self._manual_exit_url()
for h in chain: for h in chain:
@@ -410,11 +473,8 @@ class ChainService:
exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout) exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)
log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none") log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none")
if not exit_ip or (real_ip and _is_same_network(exit_ip, real_ip)): if is_chain_leak(exit_ip, real_ip, self._vpn.active):
reason = ( reason = leak_reason(exit_ip, real_ip, self._vpn.active)
"exit IP gone" if not exit_ip
else f"exit {exit_ip} matches real network {real_ip} (VPN leak)"
)
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip}) self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"Health check failed ({reason}) — rotating."}) self._notify({"type": "log", "text": f"Health check failed ({reason}) — rotating."})
break break
@@ -627,6 +687,9 @@ class ChainService:
if self._force_rotate.is_set(): if self._force_rotate.is_set():
self._force_rotate.clear() self._force_rotate.clear()
self._notify({"type": "log", "text": "Manual rotate triggered."}) self._notify({"type": "log", "text": "Manual rotate triggered."})
if self._settings.flush_dns_on_rotate:
ok, msg = flush_dns_cache()
self._notify({"type": "log", "text": f"DNS flush: {msg}" if ok else f"DNS flush failed: {msg}"})
return "rotate" return "rotate"
remaining = end - time.monotonic() remaining = end - time.monotonic()
self._notify({"type": "countdown", "secs": max(0, int(remaining))}) self._notify({"type": "countdown", "secs": max(0, int(remaining))})

View File

@@ -1,36 +1,73 @@
"""System-tray icon: green = healthy chain, red = broken/stopped, yellow = connecting.""" """System tray: LED-style proxy on/off + hover tooltip with exit IP."""
from __future__ import annotations from __future__ import annotations
import threading import threading
from typing import Any, Callable from typing import Any, Callable
import pystray import pystray
from PIL import Image, ImageDraw from PIL import Image, ImageDraw, ImageFilter
_SIZE = 64
def _circle_icon(color: str, size: int = 64) -> Image.Image: def _hex_rgb(h: str) -> tuple[int, int, int]:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) h = h.lstrip("#")
draw = ImageDraw.Draw(img) return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
pad = 4
draw.ellipse([pad, pad, size - pad, size - pad], fill=color)
return img def _led_icon(led: str, glow: str, rim: str = "#2d3748") -> Image.Image:
"""Traffic-light style icon: dark housing + glowing LED."""
base = Image.new("RGBA", (_SIZE, _SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(base)
cx, cy = _SIZE // 2, _SIZE // 2
# Outer housing (rounded rect feel via ellipse)
draw.ellipse([4, 4, _SIZE - 4, _SIZE - 4], fill="#0d0d1a", outline=rim, width=2)
draw.ellipse([10, 10, _SIZE - 10, _SIZE - 10], fill="#12122a", outline="#1a1a3e", width=1)
glow_layer = Image.new("RGBA", (_SIZE, _SIZE), (0, 0, 0, 0))
g = ImageDraw.Draw(glow_layer)
lr, lg, lb = _hex_rgb(glow)
for radius, alpha in ((22, 35), (16, 70), (11, 120)):
g.ellipse(
[cx - radius, cy - radius, cx + radius, cy + radius],
fill=(lr, lg, lb, alpha),
)
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=2))
base = Image.alpha_composite(base, glow_layer)
draw = ImageDraw.Draw(base)
dr, dg, db = _hex_rgb(led)
draw.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], fill=(dr, dg, db, 255))
draw.ellipse([cx - 5, cy - 6, cx + 2, cy - 1], fill=(255, 255, 255, 90))
return base
ICONS = { ICONS = {
"green": _circle_icon("#00e676"), "green": _led_icon("#00e676", "#00e676"),
"red": _circle_icon("#ff1744"), "red": _led_icon("#ff1744", "#ff1744"),
"yellow": _circle_icon("#ffab00"), "yellow": _led_icon("#ffab00", "#ffab00"),
"gray": _circle_icon("#6c757d"), "gray": _led_icon("#4a5568", "#3d4a5c", rim="#374151"),
} }
TIPS = { _STATE_LABEL = {
"green": "Proxy Chain: healthy", "green": "PROXY ON",
"red": "Proxy Chain: broken / stopped", "red": "PROXY OFF / ERROR",
"yellow": "Proxy Chain: connecting", "yellow": "CONNECTING",
"gray": "Proxy Chain: idle", "gray": "STOPPED",
} }
def _tooltip(state: str, exit_ip: str | None) -> str:
label = _STATE_LABEL.get(state, "Proxy God")
if exit_ip:
return f"Proxy God — {label}\nExit IP: {exit_ip}"
if state == "green":
return f"Proxy God — {label}\nExit IP: (checking…)"
return f"Proxy God — {label}\nExit IP: —"
class TrayIcon: class TrayIcon:
def __init__( def __init__(
self, self,
@@ -44,22 +81,26 @@ class TrayIcon:
self._icon: pystray.Icon | None = None self._icon: pystray.Icon | None = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._state = "gray" self._state = "gray"
self._exit_ip: str | None = None
self._lock = threading.Lock()
def start(self) -> None: def start(self) -> None:
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
return return
menu = pystray.Menu( menu = pystray.Menu(
pystray.MenuItem("Show", self._show, default=True), pystray.MenuItem("Show Proxy God", self._show, default=True),
pystray.MenuItem("Rotate now", self._rotate), pystray.MenuItem("Rotate chain now", self._rotate),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", self._quit), pystray.MenuItem("Quit", self._quit),
) )
self._icon = pystray.Icon( with self._lock:
"ProxyChainManager", tip = _tooltip(self._state, self._exit_ip)
icon=ICONS[self._state], self._icon = pystray.Icon(
title=TIPS[self._state], "ProxyGod",
menu=menu, icon=ICONS[self._state],
) title=tip,
menu=menu,
)
self._thread = threading.Thread(target=self._icon.run, daemon=True) self._thread = threading.Thread(target=self._icon.run, daemon=True)
self._thread.start() self._thread.start()
@@ -70,14 +111,27 @@ class TrayIcon:
except Exception: except Exception:
pass pass
def set_state(self, state: str) -> None: def set_state(self, state: str, exit_ip: str | None = None) -> None:
"""state: 'green', 'red', 'yellow', 'gray'.""" """state: green (on), red (error), yellow (connecting), gray (stopped).
Pass exit_ip to refresh the hover tooltip. Use exit_ip=None to keep the last IP.
"""
if state not in ICONS: if state not in ICONS:
state = "gray" state = "gray"
self._state = state with self._lock:
if self._icon: self._state = state
self._icon.icon = ICONS[state] if exit_ip is not None:
self._icon.title = TIPS[state] self._exit_ip = exit_ip.strip() if exit_ip else None
if self._icon:
self._icon.icon = ICONS[state]
self._icon.title = _tooltip(state, self._exit_ip)
def set_exit_ip(self, exit_ip: str | None) -> None:
"""Update tooltip only (e.g. after health check, same LED color)."""
with self._lock:
self._exit_ip = exit_ip.strip() if exit_ip else None
if self._icon:
self._icon.title = _tooltip(self._state, self._exit_ip)
def _show(self, icon: Any = None, item: Any = None) -> None: def _show(self, icon: Any = None, item: Any = None) -> None:
self._on_show() self._on_show()

View File

@@ -0,0 +1,156 @@
"""Detect whether a VPN tunnel is active on Windows (any provider)."""
from __future__ import annotations
import glob
import logging
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
log = logging.getLogger(__name__)
# Adapter description / name substrings (case-insensitive)
_VPN_ADAPTER_HINTS = (
"nordlynx", "nordvpn", "openvpn", "wireguard", "wintun", "tap-windows",
"tailscale", "zerotier", "cisco anyconnect", "fortinet", "pulse secure",
"globalprotect", "softether", "proton", "mullvad", "expressvpn",
"surfshark", "private internet", "pia ", "windscribe", "hotspot shield",
"tunnel", "vpn",
)
# Executables to whitelist in kill-switch when present
_VPN_EXE_GLOBS: list[str] = [
r"C:\Program Files\NordVPN\*.exe",
r"C:\Program Files\NordUpdater\*.exe",
r"C:\Program Files\NordVPN\NordSec ThreatProtection\*.exe",
r"C:\Program Files\OpenVPN\bin\*.exe",
r"C:\Program Files\OpenVPN Connect\*.exe",
r"C:\Program Files\WireGuard\*.exe",
r"C:\Program Files\Proton\VPN\*.exe",
r"C:\Program Files\Mullvad VPN\*.exe",
r"C:\Program Files\ExpressVPN\*.exe",
r"C:\Program Files\Surfshark\*.exe",
r"C:\Program Files\Private Internet Access\*.exe",
r"C:\Program Files\Tailscale\*.exe",
r"C:\Program Files\ZeroTier\One\*.exe",
]
_PROVIDER_FROM_ADAPTER: list[tuple[str, str]] = [
("nordlynx", "NordVPN"),
("nordvpn", "NordVPN"),
("wireguard", "WireGuard"),
("wintun", "WireGuard"),
("openvpn", "OpenVPN"),
("proton", "Proton VPN"),
("mullvad", "Mullvad"),
("expressvpn", "ExpressVPN"),
("surfshark", "Surfshark"),
("tailscale", "Tailscale"),
("zerotier", "ZeroTier"),
("tap-windows", "OpenVPN/TAP"),
("globalprotect", "GlobalProtect"),
("fortinet", "FortiClient"),
("cisco", "Cisco VPN"),
]
@dataclass
class VpnStatus:
active: bool = False
label: str = "Direct (no VPN)"
adapter: str = ""
adapters: list[str] = field(default_factory=list)
def short_label(self) -> str:
if not self.active:
return "Direct"
return self.label
def _run_ps(script: str, timeout: float = 12.0) -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return (r.stdout or "").strip()
except Exception as e:
log.debug("vpn_detect powershell failed: %s", e)
return ""
def _match_provider(name: str) -> str:
low = name.lower()
for hint, label in _PROVIDER_FROM_ADAPTER:
if hint in low:
return label
if "vpn" in low or "tunnel" in low:
return "VPN"
return "VPN"
def detect_vpn() -> VpnStatus:
"""Inspect up network adapters for VPN/tunnel interfaces."""
out = _run_ps(
"Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
"Select-Object -ExpandProperty Name"
)
if not out:
# Fallback: netsh
try:
r = subprocess.run(
["netsh", "interface", "show", "interface"],
capture_output=True,
text=True,
timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
lines = (r.stdout or "").splitlines()
names = []
for ln in lines[3:]:
parts = ln.split()
if len(parts) >= 4 and parts[0] == "Enabled":
names.append(" ".join(parts[3:]))
out = "\n".join(names)
except Exception:
return VpnStatus()
adapters: list[str] = []
for line in out.splitlines():
name = line.strip()
if name:
adapters.append(name)
hits: list[str] = []
for name in adapters:
low = name.lower()
if any(h in low for h in _VPN_ADAPTER_HINTS):
hits.append(name)
if not hits:
return VpnStatus(active=False, label="Direct (no VPN)", adapters=adapters)
primary = hits[0]
return VpnStatus(
active=True,
label=_match_provider(primary),
adapter=primary,
adapters=adapters,
)
def expand_vpn_executables() -> list[str]:
"""Paths to VPN client binaries for firewall allow rules."""
seen: set[str] = set()
out: list[str] = []
for pattern in _VPN_EXE_GLOBS:
for p in glob.glob(pattern):
rp = str(Path(p).resolve())
if rp not in seen:
seen.add(rp)
out.append(rp)
return out

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import unittest import unittest
from proxy_chain_manager.service import _is_same_network from proxy_chain_manager.leak_detect import is_chain_leak, is_same_subnet
from proxy_chain_manager.config import ( from proxy_chain_manager.config import (
Settings, Settings,
@@ -24,20 +24,34 @@ from proxy_chain_manager.validator import (
class TestSameNetwork(unittest.TestCase): class TestSameNetwork(unittest.TestCase):
def test_exact_match(self) -> None: def test_exact_match(self) -> None:
self.assertTrue(_is_same_network("1.2.3.4", "1.2.3.4")) self.assertTrue(is_same_subnet("1.2.3.4", "1.2.3.4"))
def test_same_slash16(self) -> None: def test_same_slash16(self) -> None:
self.assertTrue(_is_same_network("185.220.101.5", "185.220.200.9")) self.assertTrue(is_same_subnet("185.220.101.5", "185.220.200.9"))
def test_different_slash16(self) -> None: def test_different_slash16(self) -> None:
self.assertFalse(_is_same_network("185.220.101.5", "104.28.3.99")) self.assertFalse(is_same_subnet("185.220.101.5", "104.28.3.99"))
def test_none_safe(self) -> None: def test_none_safe(self) -> None:
self.assertFalse(_is_same_network(None, "1.2.3.4")) self.assertFalse(is_same_subnet(None, "1.2.3.4"))
self.assertFalse(_is_same_network("1.2.3.4", None)) self.assertFalse(is_same_subnet("1.2.3.4", None))
def test_non_ipv4_safe(self) -> None: def test_non_ipv4_safe(self) -> None:
self.assertFalse(_is_same_network("not-an-ip", "1.2.3.4")) self.assertFalse(is_same_subnet("not-an-ip", "1.2.3.4"))
class TestLeakDetect(unittest.TestCase):
def test_strict_no_vpn_same_ip(self) -> None:
self.assertTrue(is_chain_leak("1.2.3.4", "1.2.3.4", vpn_active=False))
def test_strict_no_vpn_different_ip(self) -> None:
self.assertFalse(is_chain_leak("5.6.7.8", "1.2.3.4", vpn_active=False))
def test_vpn_subnet_leak(self) -> None:
self.assertTrue(is_chain_leak("185.220.101.5", "185.220.200.9", vpn_active=True))
def test_vpn_different_subnet_ok(self) -> None:
self.assertFalse(is_chain_leak("104.28.3.99", "185.220.101.5", vpn_active=True))
class TestConfig(unittest.TestCase): class TestConfig(unittest.TestCase):