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:
File diff suppressed because it is too large
Load Diff
132
proxy_chain_manager/browser_launcher.py
Normal file
132
proxy_chain_manager/browser_launcher.py
Normal 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."
|
||||
135
proxy_chain_manager/browser_profile.py
Normal file
135
proxy_chain_manager/browser_profile.py
Normal 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")
|
||||
@@ -115,7 +115,7 @@ class Settings:
|
||||
# ── 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
|
||||
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
|
||||
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)
|
||||
manual_exit_proxy: str = ""
|
||||
@@ -135,6 +135,29 @@ class Settings:
|
||||
kill_switch_enabled: bool = True # engage Windows Firewall kill-switch when running
|
||||
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: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
|
||||
112
proxy_chain_manager/dns_leak.py
Normal file
112
proxy_chain_manager/dns_leak.py
Normal 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,
|
||||
)
|
||||
221
proxy_chain_manager/fingerprint.py
Normal file
221
proxy_chain_manager/fingerprint.py
Normal 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()],
|
||||
)
|
||||
@@ -13,24 +13,18 @@ When disengaged:
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import glob
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .paths import gost_exe_path
|
||||
from .vpn_detect import expand_vpn_executables
|
||||
|
||||
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:
|
||||
@@ -99,13 +93,6 @@ def _resolve_self_exe() -> Path:
|
||||
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():
|
||||
@@ -113,7 +100,7 @@ def engage(gost_path: Path | None = None) -> tuple[bool, str]:
|
||||
|
||||
gost = gost_path or gost_exe_path()
|
||||
self_exe = _resolve_self_exe()
|
||||
nord_exes = _expand_nord_exes()
|
||||
vpn_exes = expand_vpn_executables()
|
||||
|
||||
_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",
|
||||
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 VPN client executables (Nord, WireGuard, OpenVPN, etc.)
|
||||
for i, vpath in enumerate(vpn_exes):
|
||||
_add_rule(f"VPN_{i}", dir="out", action="allow",
|
||||
program=f'"{vpath}"', protocol="any")
|
||||
|
||||
# Allow DHCP (or you lose your adapter)
|
||||
_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_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."
|
||||
log.info("Firewall kill-switch engaged. %d VPN exes whitelisted.", len(vpn_exes))
|
||||
return True, f"Kill-switch ON. {len(vpn_exes)} VPN client(s) whitelisted."
|
||||
|
||||
|
||||
def disengage() -> tuple[bool, str]:
|
||||
|
||||
41
proxy_chain_manager/gui_validate.py
Normal file
41
proxy_chain_manager/gui_validate.py
Normal 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)
|
||||
)
|
||||
55
proxy_chain_manager/leak_detect.py
Normal file
55
proxy_chain_manager/leak_detect.py
Normal 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"
|
||||
113
proxy_chain_manager/mac_spoof.py
Normal file
113
proxy_chain_manager/mac_spoof.py
Normal 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
|
||||
106
proxy_chain_manager/pool_ops.py
Normal file
106
proxy_chain_manager/pool_ops.py
Normal 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))
|
||||
201
proxy_chain_manager/proxy_picker.py
Normal file
201
proxy_chain_manager/proxy_picker.py
Normal 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)
|
||||
@@ -16,11 +16,23 @@ from .config import (
|
||||
redact_proxy_url,
|
||||
save_settings,
|
||||
)
|
||||
from .dns_leak import flush_dns_cache
|
||||
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 .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 .validator import check_chain_exit_ip, get_direct_ip, validate_proxies
|
||||
from .vpn_detect import VpnStatus, detect_vpn
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,30 +42,8 @@ Notify = Callable[[dict[str, Any]], None]
|
||||
_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:
|
||||
"""True if two IPv4 addresses share the same /<prefix_len> subnet.
|
||||
|
||||
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
|
||||
# Back-compat for tests
|
||||
from .leak_detect import is_same_subnet as _is_same_network # noqa: F401
|
||||
|
||||
|
||||
class ChainService:
|
||||
@@ -74,6 +64,12 @@ class ChainService:
|
||||
# Per-session blacklist: proxies that crashed GOST immediately
|
||||
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:
|
||||
u = normalize_proxy_url(self._settings.manual_exit_proxy)
|
||||
return u if u else None
|
||||
@@ -116,11 +112,61 @@ class ChainService:
|
||||
def _teardown_network(self) -> None:
|
||||
clear_system_proxy()
|
||||
self._notify({"type": "log", "text": "System proxy cleared."})
|
||||
self._restore_privacy()
|
||||
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 _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:
|
||||
try:
|
||||
asyncio.run(self._async_main())
|
||||
@@ -165,13 +211,34 @@ class ChainService:
|
||||
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
|
||||
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)
|
||||
if 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:
|
||||
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 ──────────────────────────────────────────────
|
||||
if self._settings.kill_switch_enabled:
|
||||
@@ -301,6 +368,10 @@ class ChainService:
|
||||
self._current_chain = list(chain)
|
||||
self._notify({"type": "hops", "hops": chain, "status": "connecting"})
|
||||
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()
|
||||
cmd = build_gost_cmd(gost, listen, chain)
|
||||
self._notify({
|
||||
@@ -358,19 +429,11 @@ class ChainService:
|
||||
self._proc = None
|
||||
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": "log",
|
||||
"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,
|
||||
)
|
||||
self._notify({"type": "log", "text": f"Leak detected — {reason}. Rotating."})
|
||||
log.warning("Chain leak: exit=%s real=%s vpn=%s — %s", exit_ip, real_ip, self._vpn.active, reason)
|
||||
# Blacklist the whole chain so we don't reuse broken proxies
|
||||
fixed = self._manual_exit_url()
|
||||
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)
|
||||
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)):
|
||||
reason = (
|
||||
"exit IP gone" if not exit_ip
|
||||
else f"exit {exit_ip} matches real network {real_ip} (VPN leak)"
|
||||
)
|
||||
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": "log", "text": f"Health check failed ({reason}) — rotating."})
|
||||
break
|
||||
@@ -627,6 +687,9 @@ class ChainService:
|
||||
if self._force_rotate.is_set():
|
||||
self._force_rotate.clear()
|
||||
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"
|
||||
remaining = end - time.monotonic()
|
||||
self._notify({"type": "countdown", "secs": max(0, int(remaining))})
|
||||
|
||||
@@ -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
|
||||
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
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:
|
||||
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
|
||||
def _hex_rgb(h: str) -> tuple[int, int, int]:
|
||||
h = h.lstrip("#")
|
||||
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||
|
||||
|
||||
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 = {
|
||||
"green": _circle_icon("#00e676"),
|
||||
"red": _circle_icon("#ff1744"),
|
||||
"yellow": _circle_icon("#ffab00"),
|
||||
"gray": _circle_icon("#6c757d"),
|
||||
"green": _led_icon("#00e676", "#00e676"),
|
||||
"red": _led_icon("#ff1744", "#ff1744"),
|
||||
"yellow": _led_icon("#ffab00", "#ffab00"),
|
||||
"gray": _led_icon("#4a5568", "#3d4a5c", rim="#374151"),
|
||||
}
|
||||
|
||||
TIPS = {
|
||||
"green": "Proxy Chain: healthy",
|
||||
"red": "Proxy Chain: broken / stopped",
|
||||
"yellow": "Proxy Chain: connecting…",
|
||||
"gray": "Proxy Chain: idle",
|
||||
_STATE_LABEL = {
|
||||
"green": "PROXY ON",
|
||||
"red": "PROXY OFF / ERROR",
|
||||
"yellow": "CONNECTING…",
|
||||
"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:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -44,22 +81,26 @@ class TrayIcon:
|
||||
self._icon: pystray.Icon | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._state = "gray"
|
||||
self._exit_ip: str | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
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.MenuItem("Show Proxy God", self._show, default=True),
|
||||
pystray.MenuItem("Rotate chain 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,
|
||||
)
|
||||
with self._lock:
|
||||
tip = _tooltip(self._state, self._exit_ip)
|
||||
self._icon = pystray.Icon(
|
||||
"ProxyGod",
|
||||
icon=ICONS[self._state],
|
||||
title=tip,
|
||||
menu=menu,
|
||||
)
|
||||
self._thread = threading.Thread(target=self._icon.run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
@@ -70,14 +111,27 @@ class TrayIcon:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_state(self, state: str) -> None:
|
||||
"""state: 'green', 'red', 'yellow', 'gray'."""
|
||||
def set_state(self, state: str, exit_ip: str | None = None) -> None:
|
||||
"""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:
|
||||
state = "gray"
|
||||
self._state = state
|
||||
if self._icon:
|
||||
self._icon.icon = ICONS[state]
|
||||
self._icon.title = TIPS[state]
|
||||
with self._lock:
|
||||
self._state = state
|
||||
if exit_ip is not None:
|
||||
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:
|
||||
self._on_show()
|
||||
|
||||
156
proxy_chain_manager/vpn_detect.py
Normal file
156
proxy_chain_manager/vpn_detect.py
Normal 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
|
||||
Reference in New Issue
Block a user