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>
157 lines
4.6 KiB
Python
157 lines
4.6 KiB
Python
"""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
|