This commit is contained in:
2025-09-07 23:33:55 +02:00
parent d918f1e497
commit fbacfde9f2
33 changed files with 2626 additions and 1236 deletions

View File

@@ -1,13 +1,13 @@
"""VPN connection management using NetworkManager (nmcli)."""
"""Enhanced VPN management with VPNTray naming and route control."""
import subprocess
import tempfile
import os
import re
import logging
from dataclasses import dataclass
from typing import Optional, Dict, List
from typing import Optional, List
from enum import Enum
from pathlib import Path
from models import Location
class VPNStatus(Enum):
@@ -26,286 +26,482 @@ class VPNConnectionError(Exception):
@dataclass
class VPNConnection:
"""Represents a NetworkManager VPN connection."""
class VPNConnectionInfo:
"""Information about a VPN connection."""
name: str
uuid: str
type: str
vpntray_name: str # Our custom name with vpntray_ prefix
status: VPNStatus
device: Optional[str] = None
state: VPNStatus = VPNStatus.UNKNOWN
vpn_type: Optional[str] = None # OpenVPN, WireGuard, etc.
routes: List[str] = None # List of routes added
class VPNManager:
"""Manages VPN connections through NetworkManager CLI (nmcli)."""
"""Enhanced VPN manager with VPNTray naming and route management."""
VPNTRAY_PREFIX = "vpntray_"
VPN_CONFIG_DIR = Path.home() / ".vpntray" / "vpn"
def __init__(self):
"""Initialize VPN manager and check for nmcli availability."""
"""Initialize VPN manager."""
self.logger = logging.getLogger(__name__)
self._check_nmcli_available()
self._ensure_vpn_config_dir()
def _check_nmcli_available(self) -> None:
"""Check if nmcli is available on the system."""
"""Check if nmcli is available."""
try:
subprocess.run(['nmcli', '--version'],
capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
raise VPNConnectionError(
"nmcli is not available. Please install NetworkManager.")
"nmcli is not available. Install NetworkManager.")
def _ensure_vpn_config_dir(self) -> None:
"""Ensure VPN config directory exists."""
self.VPN_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
def _run_nmcli(self, args: List[str], check: bool = True, timeout: int = 30) -> subprocess.CompletedProcess:
"""Run nmcli command with logging and timeout."""
command = ['nmcli'] + args
command_str = ' '.join(command)
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,
command,
capture_output=True,
text=True,
check=check
check=check,
timeout=timeout # Add timeout to prevent hanging
)
self.logger.debug(f"Command: {command_str}")
if result.stdout.strip():
self.logger.debug(f"Output: {result.stdout.strip()}")
if result.stderr.strip():
self.logger.warning(f"Stderr: {result.stderr.strip()}")
if result.returncode == 0:
self.logger.debug("Command completed successfully")
else:
self.logger.error(
f"Command exited with code: {result.returncode}")
return result
except subprocess.TimeoutExpired:
self.logger.error(
f"Command timed out after {timeout}s: {command_str}")
raise VPNConnectionError(
f"nmcli command timed out after {timeout} seconds")
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():
self.logger.debug(f"Failed command: {command_str}")
if e.stdout and e.stdout.strip():
self.logger.debug(f"Output: {e.stdout.strip()}")
if e.stderr and e.stderr.strip():
self.logger.error(f"Error: {e.stderr.strip()}")
error_details = e.stderr or str(e)
raise VPNConnectionError(
f"OpenVPN config file not found: {ovpn_path}")
f"nmcli command failed (exit code {e.returncode}): {error_details}")
# Import the configuration
result = self._run_nmcli([
'connection', 'import', 'type', 'openvpn', 'file', str(ovpn_file)
])
def _get_vpntray_connection_name(self, config_filename: str) -> str:
"""Generate VPNTray-specific connection name."""
# Remove extension and sanitize
base_name = Path(config_filename).stem
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', base_name)
return f"{self.VPNTRAY_PREFIX}{sanitized}"
# 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)
def get_vpn_config_path(self, filename: str) -> Path:
"""Get full path to VPN config file."""
return self.VPN_CONFIG_DIR / filename
def list_vpntray_connections(self) -> List[VPNConnectionInfo]:
"""List all VPNTray-managed connections."""
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
try:
result = self._run_nmcli(['connection', 'show'])
for line in result.stdout.strip().split('\\n'):
if self.VPNTRAY_PREFIX in line:
parts = line.split()
if len(parts) >= 4:
name = parts[0]
uuid = parts[1]
device = parts[3] if parts[3] != '--' else None
# Get current status
status = self.get_status(name)
# Get detailed status
status = self._get_connection_status(name)
connections.append(VPNConnection(
name=name,
uuid=uuid,
type=conn_type,
device=device,
state=status
))
connections.append(VPNConnectionInfo(
name=name,
uuid=uuid,
vpntray_name=name,
status=status,
device=device
))
except VPNConnectionError:
pass # No connections or nmcli error
return connections
def delete_connection(self, connection_name: str) -> None:
"""Delete a NetworkManager connection.
def _get_connection_status(self, connection_name: str) -> VPNStatus:
"""Get the status of a specific connection."""
try:
result = self._run_nmcli(['connection', 'show', connection_name])
Args:
connection_name: Name of the connection to delete
"""
self._run_nmcli(['connection', 'delete', connection_name])
# Parse connection state from output
for line in result.stdout.split('\\n'):
if 'GENERAL.STATE:' in line:
state = line.split(':')[1].strip()
if 'activated' in state.lower():
return VPNStatus.CONNECTED
elif 'activating' in state.lower():
return VPNStatus.CONNECTING
elif 'deactivating' in state.lower():
return VPNStatus.DISCONNECTING
else:
return VPNStatus.DISCONNECTED
except VPNConnectionError:
pass
def connection_exists(self, connection_name: str) -> bool:
"""Check if a connection exists.
return VPNStatus.UNKNOWN
Args:
connection_name: Name of the connection to check
def import_vpn_config(self, location: Location) -> str:
"""Import VPN configuration for a location with VPNTray naming."""
config_path = self.get_vpn_config_path(location.vpn_config)
Returns:
True if the connection exists
"""
result = self._run_nmcli(
['connection', 'show', connection_name],
check=False
)
return result.returncode == 0
if not config_path.exists():
raise VPNConnectionError(f"VPN config not found: {config_path}")
def modify_connection(self, connection_name: str,
settings: Dict[str, str]) -> None:
"""Modify connection settings.
self.logger.info(
f"Config file exists: {config_path} ({config_path.stat().st_size} bytes)")
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():
vpntray_name = self._get_vpntray_connection_name(location.vpn_config)
# Check if already imported
if self._get_connection_by_name(vpntray_name):
self.logger.info(f"Connection already imported: {vpntray_name}")
return vpntray_name
# Import based on VPN type
self.logger.info(
f"Importing {location.vpn_type.value} config: {config_path.name}")
if location.vpn_type.value == "OpenVPN":
return self._import_openvpn(config_path, vpntray_name, location)
elif location.vpn_type.value == "WireGuard":
return self._import_wireguard(config_path, vpntray_name, location)
else:
raise VPNConnectionError(
f"Unsupported VPN type: {location.vpn_type.value}")
def _import_openvpn(self, config_path: Path, vpntray_name: str, location: Location) -> str:
"""Import OpenVPN configuration with route control."""
# Import the config file first (nmcli will auto-generate a name)
import_args = [
'connection', 'import', 'type', 'openvpn',
'file', str(config_path)
]
self.logger.info(f"Running nmcli import: {' '.join(import_args)}")
try:
result = self._run_nmcli(import_args)
# Extract the auto-generated connection name from the output
# nmcli outputs: "Connection 'name' (uuid) successfully added."
import re
match = re.search(r"Connection '([^']+)'", result.stdout)
if not match:
raise VPNConnectionError(
"Failed to parse imported connection name from nmcli output")
auto_generated_name = match.group(1)
self.logger.info(
f"Config imported with auto name: {auto_generated_name}")
# Rename to our VPNTray naming convention
rename_args = [
'connection', 'modify', auto_generated_name,
'connection.id', vpntray_name
]
self.logger.info(f"Renaming to: {vpntray_name}")
self._run_nmcli(rename_args)
self.logger.info(
f"OpenVPN config imported as {vpntray_name}")
except VPNConnectionError as e:
self.logger.error(f"OpenVPN import failed: {e}")
raise
# Configure credentials immediately after import if provided
if location.vpn_credentials:
self._configure_credentials(vpntray_name, location)
# Configure the connection to not route everything by default
self._configure_connection_routes(vpntray_name, location)
return vpntray_name
def _import_wireguard(self, config_path: Path, vpntray_name: str, location: Location) -> str:
"""Import WireGuard configuration with route control."""
# Import the config file first (nmcli will auto-generate a name)
import_args = [
'connection', 'import', 'type', 'wireguard',
'file', str(config_path)
]
self.logger.info(
f"Running nmcli import: {' '.join(import_args)}")
try:
result = self._run_nmcli(import_args)
# Extract the auto-generated connection name from the output
# nmcli outputs: "Connection 'name' (uuid) successfully added."
import re
match = re.search(r"Connection '([^']+)'", result.stdout)
if not match:
raise VPNConnectionError(
"Failed to parse imported connection name from nmcli output")
auto_generated_name = match.group(1)
self.logger.info(
f"Config imported with auto name: {auto_generated_name}")
# Rename to our VPNTray naming convention
rename_args = [
'connection', 'modify', auto_generated_name,
'connection.id', vpntray_name
]
self.logger.info(f"Renaming to: {vpntray_name}")
self._run_nmcli(rename_args)
self.logger.info(
f"WireGuard config imported as {vpntray_name}")
except VPNConnectionError as e:
self.logger.error(f"WireGuard import failed: {e}")
raise
# Configure credentials immediately after import if provided
if location.vpn_credentials:
self._configure_credentials(vpntray_name, location)
# Configure routes
self._configure_connection_routes(vpntray_name, location)
return vpntray_name
def _configure_connection_routes(self, connection_name: str, location: Location) -> None:
"""Configure connection to only route specified network segments."""
try:
# Disable automatic default route
self._run_nmcli([
'connection', 'modify', connection_name,
key, value
'ipv4.never-default', 'true'
])
def get_connection_details(self, connection_name: str) -> Dict[str, str]:
"""Get detailed information about a connection.
# Add routes for each network segment
routes = []
for segment in location.network_segments:
# Add route for the network segment
routes.append(segment.cidr)
Args:
connection_name: Name of the connection
if routes:
routes_str = ','.join(routes)
self._run_nmcli([
'connection', 'modify', connection_name,
'ipv4.routes', routes_str
])
self.logger.info(
f"Configured routes for {connection_name}: {routes_str}")
Returns:
Dictionary of connection properties
"""
result = self._run_nmcli(['connection', 'show', connection_name])
except VPNConnectionError as e:
self.logger.error(f"Failed to configure routes: {e}")
# Don't fail the import, just log the error
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:
def _get_connection_by_name(self, name: str) -> Optional[VPNConnectionInfo]:
"""Get connection info by name."""
try:
# Check if connection exists (simple and fast)
result = self._run_nmcli(['connection', 'show', name], check=False)
if result.returncode == 0:
# Connection exists, create minimal info object
return VPNConnectionInfo(
name=name,
uuid="unknown",
vpntray_name=name,
status=VPNStatus.UNKNOWN # Status will be checked when needed
)
return None
except VPNConnectionError:
return None
details = self.get_connection_details(connection_name)
return details.get('GENERAL.DEVICES')
def connect_vpn(self, location: Location) -> bool:
"""Connect to VPN for a location."""
try:
vpntray_name = self._get_vpntray_connection_name(
location.vpn_config)
config_path = self.get_vpn_config_path(location.vpn_config)
self.logger.info(f"VPN config: {config_path}")
self.logger.info(f"Connection name: {vpntray_name}")
def get_vpn_ip_address(self, connection_name: str) -> Optional[str]:
"""Get the IP address assigned to the VPN connection.
# Check if config file exists
if not config_path.exists():
error_msg = f"VPN config file not found: {config_path}"
self.logger.error(error_msg)
return False
Args:
connection_name: Name of the VPN connection
# Import if not already imported
existing_conn = self._get_connection_by_name(vpntray_name)
if not existing_conn:
self.logger.info(
"Importing VPN config for first time...")
try:
self.import_vpn_config(location)
self.logger.info(
"VPN config imported successfully")
except Exception as import_error:
error_msg = f"Failed to import VPN config: {import_error}"
self.logger.error(error_msg)
return False
else:
self.logger.info(
f"Using existing connection: {existing_conn.status.value}")
Returns:
IP address or None if not connected
"""
interface = self.get_active_vpn_interface(connection_name)
if not interface:
return None
# Connect with simple command - credentials already set during import
self.logger.info("Attempting connection...")
result = self._run_nmcli(['device', 'show', interface], check=False)
# Simple connection command without credential complications
connect_args = ['connection', 'up', vpntray_name]
self._run_nmcli(connect_args, timeout=60)
self.logger.info(f"Connected to {vpntray_name}")
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 True
return None
except VPNConnectionError as e:
self.logger.error(f"VPN connection failed: {e}")
return False
except Exception as e:
self.logger.error(
f"Unexpected error during connection: {e}")
return False
def disconnect_vpn(self, location: Location) -> bool:
"""Disconnect VPN for a location."""
try:
vpntray_name = self._get_vpntray_connection_name(
location.vpn_config)
self.logger.info(f"Disconnecting from {vpntray_name}...")
# Check if connection exists
existing_conn = self._get_connection_by_name(vpntray_name)
if not existing_conn:
self.logger.error(
f"Connection {vpntray_name} not found")
return False
# Disconnect
self._run_nmcli(['connection', 'down', vpntray_name])
self.logger.info(f"Disconnected from {vpntray_name}")
return True
except VPNConnectionError as e:
self.logger.error(f"Failed to disconnect: {e}")
return False
except Exception as e:
self.logger.error(
f"Unexpected error during disconnection: {e}")
return False
def get_connection_status(self, location: Location) -> VPNStatus:
"""Get connection status for a location."""
vpntray_name = self._get_vpntray_connection_name(location.vpn_config)
return self._get_connection_status(vpntray_name)
def remove_vpn_config(self, location: Location) -> bool:
"""Remove VPN connection configuration."""
try:
vpntray_name = self._get_vpntray_connection_name(
location.vpn_config)
# First disconnect if connected
try:
self._run_nmcli(
['connection', 'down', vpntray_name], check=False)
except VPNConnectionError:
pass # Ignore if already disconnected
# Remove the connection
self._run_nmcli(['connection', 'delete', vpntray_name])
self.logger.info(
f"Removed VPN configuration {vpntray_name}")
return True
except VPNConnectionError as e:
self.logger.error(f"Failed to remove config: {e}")
return False
def cleanup_vpntray_connections(self) -> int:
"""Remove all VPNTray-managed connections. Returns count removed."""
connections = self.list_vpntray_connections()
removed_count = 0
for conn in connections:
try:
# Disconnect first
self._run_nmcli(['connection', 'down', conn.name], check=False)
# Remove
self._run_nmcli(['connection', 'delete', conn.name])
removed_count += 1
except VPNConnectionError:
pass # Continue with other connections
if self.logger and removed_count > 0:
self.logger.info(
f"Cleaned up {removed_count} VPNTray connections")
return removed_count
def _configure_credentials(self, connection_name: str, location: Location) -> None:
"""Configure VPN credentials directly in the connection."""
if not location.vpn_credentials:
self.logger.info(
f"No credentials provided for {connection_name}")
return
try:
# Handle dictionary credentials (username/password)
if isinstance(location.vpn_credentials, dict):
username = location.vpn_credentials.get('username')
password = location.vpn_credentials.get('password')
self.logger.info(
f"Setting credentials for {connection_name}...")
# Set username and password with correct nmcli syntax
if username:
self._run_nmcli([
'connection', 'modify', connection_name,
'+vpn.data', f'username={username}'
])
self.logger.info(
f"Username configured for {connection_name}")
if password:
self._run_nmcli([
'connection', 'modify', connection_name,
'+vpn.secrets', f'password={password}'
])
self.logger.info(
f"Password configured for {connection_name}")
if username and password:
self.logger.info(
f"Full credentials configured for {connection_name}")
elif username or password:
self.logger.info(
f"Partial credentials configured for {connection_name}")
except VPNConnectionError as e:
self.logger.error(f"Failed to configure credentials: {e}")
# Don't fail the whole operation for credential issues