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:
@@ -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))})
|
||||
|
||||
Reference in New Issue
Block a user