312 lines
9.9 KiB
Python
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
|