Files
VPNTray/services/vpn_manager.py
2025-09-06 16:54:45 +02:00

312 lines
9.9 KiB
Python

"""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