"""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", ) _NON_VPN_HINTS = ( "wan miniport", "microsoft kernel debug", "isatap", "teredo", "loopback", "pseudo-interface", ) # 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' } | " "ForEach-Object { \"$($_.Name)|$($_.InterfaceDescription)\" }" ) if not out: # Fallback for PowerShell 2.0 / older Windows Server: parse "wmic nic" # then "netsh interface show interface". Both are locale-tolerant. try: r = subprocess.run( ["wmic", "nic", "where", "NetEnabled=true", "get", "NetConnectionID,Name", "/format:list"], capture_output=True, text=True, timeout=12, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) blob = (r.stdout or "") names: list[str] = [] cur_id = "" cur_name = "" for ln in blob.splitlines(): ln = ln.strip() if not ln: if cur_id: names.append(f"{cur_id}|{cur_name}") cur_id, cur_name = "", "" continue if ln.startswith("NetConnectionID="): cur_id = ln.split("=", 1)[1].strip() elif ln.startswith("Name="): cur_name = ln.split("=", 1)[1].strip() if cur_id: names.append(f"{cur_id}|{cur_name}") out = "\n".join(names) except Exception: out = "" if not out: # Last-ditch: netsh. Locale-tolerant — match the connected/enabled state # by extracting the interface name from the rightmost column. Older # localized Server SKUs (es, de, fr, etc.) don't print literal "Enabled". try: r = subprocess.run( ["netsh", "interface", "show", "interface"], capture_output=True, text=True, timeout=10, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) raw = (r.stdout or "") names = [] for ln in raw.splitlines(): s = ln.rstrip() if not s or s.startswith("-") or ":" in s.split(" ")[0]: continue parts = re.split(r"\s{2,}", s.strip()) if len(parts) >= 4: nm = parts[-1].strip() if nm and nm.lower() not in ("interface name", "nombre de interfaz"): names.append(f"{nm}|") out = "\n".join(names) except Exception: return VpnStatus() adapters: list[str] = [] adapter_blob: list[str] = [] for line in out.splitlines(): raw = line.strip() if not raw: continue if "|" in raw: name, desc = raw.split("|", 1) else: name, desc = raw, "" name = name.strip() desc = desc.strip() if name: adapters.append(name) adapter_blob.append((name + " " + desc).strip()) hits: list[str] = [] for i, name in enumerate(adapters): low = adapter_blob[i].lower() if any(h in low for h in _NON_VPN_HINTS): continue 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