"""VPN connection management using NetworkManager (nmcli).""" import subprocess import tempfile import os import re from dataclasses import dataclass from typing import Optional, Dict, List from enum import Enum from pathlib import Path class VPNStatus(Enum): """VPN connection status.""" CONNECTED = "connected" DISCONNECTED = "disconnected" CONNECTING = "connecting" DISCONNECTING = "disconnecting" FAILED = "failed" UNKNOWN = "unknown" class VPNConnectionError(Exception): """Exception raised for VPN connection errors.""" pass @dataclass class VPNConnection: """Represents a NetworkManager VPN connection.""" name: str uuid: str type: str device: Optional[str] = None state: VPNStatus = VPNStatus.UNKNOWN vpn_type: Optional[str] = None # OpenVPN, WireGuard, etc. class VPNManager: """Manages VPN connections through NetworkManager CLI (nmcli).""" def __init__(self): """Initialize VPN manager and check for nmcli availability.""" self._check_nmcli_available() def _check_nmcli_available(self) -> None: """Check if nmcli is available on the system.""" try: subprocess.run(['nmcli', '--version'], capture_output=True, check=True) except (subprocess.CalledProcessError, FileNotFoundError): raise VPNConnectionError( "nmcli is not available. Please install NetworkManager.") def _run_nmcli(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess: """Run an nmcli command with error handling.""" try: result = subprocess.run( ['nmcli'] + args, capture_output=True, text=True, check=check ) return result except subprocess.CalledProcessError as e: raise VPNConnectionError(f"nmcli command failed: {e.stderr}") def import_ovpn(self, ovpn_path: str, connection_name: Optional[str] = None) -> str: """Import an OpenVPN configuration file. Args: ovpn_path: Path to the .ovpn configuration file connection_name: Optional custom name for the connection Returns: The name of the imported connection """ ovpn_file = Path(ovpn_path) if not ovpn_file.exists(): raise VPNConnectionError( f"OpenVPN config file not found: {ovpn_path}") # Import the configuration result = self._run_nmcli([ 'connection', 'import', 'type', 'openvpn', 'file', str(ovpn_file) ]) # Extract connection name from output # nmcli typically outputs: "Connection 'name' (uuid) successfully added." match = re.search(r"Connection '([^']+)'", result.stdout) if not match: raise VPNConnectionError( "Failed to parse imported connection name") imported_name = match.group(1) # Rename if custom name provided if connection_name and connection_name != imported_name: self._run_nmcli([ 'connection', 'modify', imported_name, 'connection.id', connection_name ]) return connection_name return imported_name def connect(self, connection_name: str, username: Optional[str] = None, password: Optional[str] = None) -> None: """Connect to a VPN. Args: connection_name: Name of the NetworkManager connection username: Optional username for authentication password: Optional password for authentication """ if username and password: # Create temporary secrets file with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: f.write(f"vpn.secrets.password:{password}\n") if username: f.write(f"vpn.data.username:{username}\n") secrets_file = f.name try: self._run_nmcli([ 'connection', 'up', connection_name, 'passwd-file', secrets_file ]) finally: # Always clean up secrets file os.unlink(secrets_file) else: # Connect without credentials (will prompt if needed) self._run_nmcli(['connection', 'up', connection_name]) def disconnect(self, connection_name: str) -> None: """Disconnect from a VPN. Args: connection_name: Name of the NetworkManager connection """ self._run_nmcli(['connection', 'down', connection_name]) def get_status(self, connection_name: str) -> VPNStatus: """Get the status of a VPN connection. Args: connection_name: Name of the NetworkManager connection Returns: Current status of the VPN connection """ result = self._run_nmcli( ['connection', 'show', '--active'], check=False ) if connection_name in result.stdout: # Parse the actual state state_result = self._run_nmcli( ['connection', 'show', connection_name], check=False ) if 'GENERAL.STATE:' in state_result.stdout: if 'activated' in state_result.stdout: return VPNStatus.CONNECTED elif 'activating' in state_result.stdout: return VPNStatus.CONNECTING elif 'deactivating' in state_result.stdout: return VPNStatus.DISCONNECTING return VPNStatus.DISCONNECTED def list_connections(self, vpn_only: bool = True) -> List[VPNConnection]: """List all NetworkManager connections. Args: vpn_only: If True, only return VPN connections Returns: List of VPNConnection objects """ args = ['connection', 'show'] if vpn_only: args.extend(['--type', 'vpn']) result = self._run_nmcli(args, check=False) connections = [] for line in result.stdout.strip().split('\n')[1:]: # Skip header if not line: continue parts = line.split() if len(parts) >= 4: name = parts[0] uuid = parts[1] conn_type = parts[2] device = parts[3] if parts[3] != '--' else None # Get current status status = self.get_status(name) connections.append(VPNConnection( name=name, uuid=uuid, type=conn_type, device=device, state=status )) return connections def delete_connection(self, connection_name: str) -> None: """Delete a NetworkManager connection. Args: connection_name: Name of the connection to delete """ self._run_nmcli(['connection', 'delete', connection_name]) def connection_exists(self, connection_name: str) -> bool: """Check if a connection exists. Args: connection_name: Name of the connection to check Returns: True if the connection exists """ result = self._run_nmcli( ['connection', 'show', connection_name], check=False ) return result.returncode == 0 def modify_connection(self, connection_name: str, settings: Dict[str, str]) -> None: """Modify connection settings. Args: connection_name: Name of the connection to modify settings: Dictionary of setting key-value pairs e.g., {'vpn.data.comp-lzo': 'yes'} """ for key, value in settings.items(): self._run_nmcli([ 'connection', 'modify', connection_name, key, value ]) def get_connection_details(self, connection_name: str) -> Dict[str, str]: """Get detailed information about a connection. Args: connection_name: Name of the connection Returns: Dictionary of connection properties """ result = self._run_nmcli(['connection', 'show', connection_name]) details = {} for line in result.stdout.strip().split('\n'): if ':' in line: key, value = line.split(':', 1) details[key.strip()] = value.strip() return details def get_active_vpn_interface(self, connection_name: str) -> Optional[str]: """Get the network interface used by an active VPN connection. Args: connection_name: Name of the VPN connection Returns: Interface name (e.g., 'tun0') or None if not connected """ if self.get_status(connection_name) != VPNStatus.CONNECTED: return None details = self.get_connection_details(connection_name) return details.get('GENERAL.DEVICES') def get_vpn_ip_address(self, connection_name: str) -> Optional[str]: """Get the IP address assigned to the VPN connection. Args: connection_name: Name of the VPN connection Returns: IP address or None if not connected """ interface = self.get_active_vpn_interface(connection_name) if not interface: return None result = self._run_nmcli(['device', 'show', interface], check=False) for line in result.stdout.split('\n'): if 'IP4.ADDRESS' in line and 'IP4.ADDRESS[2]' not in line: # Format is usually "IP4.ADDRESS[1]: 10.0.0.1/24" if ':' in line: addr_part = line.split(':', 1)[1].strip() if '/' in addr_part: return addr_part.split('/')[0] return None