This commit is contained in:
2025-09-06 16:54:45 +02:00
parent a4bf07a3b6
commit d918f1e497
8 changed files with 1157 additions and 70 deletions

17
services/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""Services package for VPN and password management."""
from .vpn_manager import VPNManager, VPNConnectionError, VPNStatus, VPNConnection
from .passbolt_client import PassboltClient, PassboltError, PassboltCredential
from .connection_manager import ConnectionManager, ConnectionConfig
__all__ = [
'VPNManager',
'VPNConnection',
'VPNConnectionError',
'VPNStatus',
'PassboltClient',
'PassboltCredential',
'PassboltError',
'ConnectionManager',
'ConnectionConfig',
]

View File

@@ -0,0 +1,266 @@
"""High-level connection manager that integrates VPN and Passbolt."""
import logging
from typing import Optional, Dict, Any
from pathlib import Path
from dataclasses import dataclass
from .vpn_manager import VPNManager, VPNStatus, VPNConnectionError
from .passbolt_client import PassboltClient, PassboltError
logger = logging.getLogger(__name__)
@dataclass
class ConnectionConfig:
"""Configuration for a VPN connection."""
name: str
vpn_config_path: str
nmcli_connection_name: Optional[str] = None
auto_import: bool = True # Auto-import .ovpn if not in NetworkManager
# Credentials can be:
# - Passbolt UUID string (for future implementation)
# - Dict with 'username' and 'password' keys
# - None if no credentials needed
vpn_credentials: Optional[dict | str] = None
class ConnectionManager:
"""Manages VPN connections with Passbolt credential integration."""
def __init__(self, use_passbolt: bool = True):
"""Initialize the connection manager.
Args:
use_passbolt: Whether to use Passbolt for credentials
"""
self.vpn_manager = VPNManager()
self.passbolt_client = None
if use_passbolt:
try:
self.passbolt_client = PassboltClient()
logger.info("Passbolt client initialized successfully")
except PassboltError as e:
logger.warning(f"Passbolt not available: {e}")
logger.info("Falling back to manual credential entry")
def connect_location(self, config: ConnectionConfig,
username: Optional[str] = None,
password: Optional[str] = None) -> None:
"""Connect to a VPN location.
Args:
config: Connection configuration
username: Override username (if not using Passbolt)
password: Override password (if not using Passbolt)
"""
# Ensure connection exists in NetworkManager
connection_name = self._ensure_connection(config)
# Get credentials - check overrides first, then config
if not username or not password:
creds_username, creds_password = self._get_credentials_from_config(config)
username = username or creds_username
password = password or creds_password
if not username or not password:
logger.info(f"No credentials provided for {connection_name}")
# nmcli will prompt for credentials
# Connect
try:
logger.info(f"Connecting to {connection_name}")
self.vpn_manager.connect(connection_name, username, password)
logger.info(f"Successfully connected to {connection_name}")
except VPNConnectionError as e:
logger.error(f"Failed to connect to {connection_name}: {e}")
raise
def disconnect_location(self, config: ConnectionConfig) -> None:
"""Disconnect from a VPN location.
Args:
config: Connection configuration
"""
connection_name = config.nmcli_connection_name or config.name
if not self.vpn_manager.connection_exists(connection_name):
logger.warning(f"Connection {connection_name} does not exist")
return
try:
logger.info(f"Disconnecting from {connection_name}")
self.vpn_manager.disconnect(connection_name)
logger.info(f"Successfully disconnected from {connection_name}")
except VPNConnectionError as e:
logger.error(f"Failed to disconnect from {connection_name}: {e}")
raise
def get_connection_status(self, config: ConnectionConfig) -> VPNStatus:
"""Get the status of a VPN connection.
Args:
config: Connection configuration
Returns:
Current VPN status
"""
connection_name = config.nmcli_connection_name or config.name
if not self.vpn_manager.connection_exists(connection_name):
return VPNStatus.DISCONNECTED
return self.vpn_manager.get_status(connection_name)
def _ensure_connection(self, config: ConnectionConfig) -> str:
"""Ensure VPN connection exists in NetworkManager.
Args:
config: Connection configuration
Returns:
Name of the NetworkManager connection
"""
connection_name = config.nmcli_connection_name or config.name
# Check if connection already exists
if self.vpn_manager.connection_exists(connection_name):
logger.debug(f"Connection {connection_name} already exists")
return connection_name
# Import if auto_import is enabled and config file exists
if config.auto_import and config.vpn_config_path:
vpn_file = Path(config.vpn_config_path)
if vpn_file.exists():
logger.info(f"Importing VPN configuration from {vpn_file}")
imported_name = self.vpn_manager.import_ovpn(
str(vpn_file),
connection_name
)
logger.info(f"Imported connection as {imported_name}")
return imported_name
else:
raise VPNConnectionError(
f"VPN config file not found: {config.vpn_config_path}"
)
raise VPNConnectionError(
f"Connection {connection_name} does not exist and auto-import is disabled"
)
def _get_credentials_from_config(self, config: ConnectionConfig) -> tuple[Optional[str], Optional[str]]:
"""Get credentials from the configuration.
Args:
config: Connection configuration
Returns:
Tuple of (username, password) or (None, None)
"""
if not config.vpn_credentials:
return None, None
# If it's a dict with username/password
if isinstance(config.vpn_credentials, dict):
username = config.vpn_credentials.get('username')
password = config.vpn_credentials.get('password')
return username, password
# If it's a string (Passbolt UUID for future use)
if isinstance(config.vpn_credentials, str):
# For now, try to use Passbolt if available
if self.passbolt_client:
try:
return self._get_passbolt_credentials(config.vpn_credentials)
except (PassboltError, ValueError) as e:
logger.warning(f"Failed to get Passbolt credentials: {e}")
else:
logger.warning(f"Passbolt UUID provided but Passbolt client not available")
return None, None
def _get_passbolt_credentials(self, resource_id: str) -> tuple[str, str]:
"""Get credentials from Passbolt.
Args:
resource_id: Passbolt resource UUID
Returns:
Tuple of (username, password)
"""
if not self.passbolt_client:
raise ValueError("Passbolt client not initialized")
try:
credential = self.passbolt_client.get_credential(resource_id)
if not credential.username or not credential.password:
raise ValueError(
f"Incomplete credentials for resource {resource_id}")
return credential.username, credential.password
except PassboltError as e:
logger.error(f"Failed to get Passbolt credentials: {e}")
raise
def validate_passbolt_resource(self, resource_id: str) -> bool:
"""Validate that a Passbolt resource exists and has required fields.
Args:
resource_id: Passbolt resource UUID
Returns:
True if resource is valid for VPN use
"""
if not self.passbolt_client:
return False
try:
credential = self.passbolt_client.get_credential(resource_id)
return bool(credential.username and credential.password)
except PassboltError:
return False
def import_all_configs(self, configs: list[ConnectionConfig]) -> Dict[str, bool]:
"""Import multiple VPN configurations.
Args:
configs: List of connection configurations
Returns:
Dictionary mapping connection names to success status
"""
results = {}
for config in configs:
try:
connection_name = self._ensure_connection(config)
results[connection_name] = True
logger.info(f"Successfully imported {connection_name}")
except VPNConnectionError as e:
results[config.name] = False
logger.error(f"Failed to import {config.name}: {e}")
return results
def cleanup_connection(self, config: ConnectionConfig,
remove_from_nm: bool = False) -> None:
"""Clean up a VPN connection.
Args:
config: Connection configuration
remove_from_nm: Whether to remove from NetworkManager
"""
connection_name = config.nmcli_connection_name or config.name
# Disconnect if connected
if self.get_connection_status(config) == VPNStatus.CONNECTED:
self.disconnect_location(config)
# Remove from NetworkManager if requested
if remove_from_nm and self.vpn_manager.connection_exists(connection_name):
logger.info(f"Removing {connection_name} from NetworkManager")
self.vpn_manager.delete_connection(connection_name)

369
services/passbolt_client.py Normal file
View File

@@ -0,0 +1,369 @@
"""Passbolt CLI integration for secure credential management."""
import subprocess
import json
import os
from dataclasses import dataclass
from typing import Optional, List, Dict, Any
from enum import Enum
from pathlib import Path
class PassboltResourceType(Enum):
"""Types of resources in Passbolt."""
PASSWORD = "password"
PASSWORD_WITH_DESCRIPTION = "password-with-description"
PASSWORD_STRING = "password-string"
TOTP = "totp"
class PassboltError(Exception):
"""Exception raised for Passbolt operations."""
pass
@dataclass
class PassboltCredential:
"""Represents credentials retrieved from Passbolt."""
resource_id: str
name: str
username: Optional[str] = None
password: Optional[str] = None
uri: Optional[str] = None
description: Optional[str] = None
resource_type: PassboltResourceType = PassboltResourceType.PASSWORD
@dataclass
class PassboltResource:
"""Represents a Passbolt resource."""
id: str
name: str
username: Optional[str] = None
uri: Optional[str] = None
resource_type: str = "password"
folder_parent_id: Optional[str] = None
personal: bool = False
class PassboltClient:
"""Client for interacting with Passbolt through the CLI."""
def __init__(self, passbolt_cli_path: str = "passbolt"):
"""Initialize Passbolt client.
Args:
passbolt_cli_path: Path to the passbolt CLI executable
"""
self.cli_path = passbolt_cli_path
self._check_cli_available()
self._check_authentication()
def _check_cli_available(self) -> None:
"""Check if Passbolt CLI is available."""
try:
subprocess.run([self.cli_path, '--version'],
capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
raise PassboltError(
f"Passbolt CLI not found at '{self.cli_path}'. "
"Please install: https://github.com/passbolt/go-passbolt-cli"
)
def _check_authentication(self) -> None:
"""Check if authenticated with Passbolt."""
try:
# Try to list resources to check auth
self._run_passbolt(['list', '--json'], check=True)
except PassboltError:
raise PassboltError(
"Not authenticated with Passbolt. "
"Please run: passbolt auth login"
)
def _run_passbolt(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess:
"""Run a Passbolt CLI command.
Args:
args: Command arguments
check: Whether to check return code
Returns:
Completed process result
"""
try:
result = subprocess.run(
[self.cli_path] + args,
capture_output=True,
text=True,
check=check
)
return result
except subprocess.CalledProcessError as e:
raise PassboltError(f"Passbolt command failed: {e.stderr}")
def get_credential(self, resource_id: str) -> PassboltCredential:
"""Get a credential by resource ID.
Args:
resource_id: UUID of the Passbolt resource
Returns:
PassboltCredential object with username and password
"""
# Get the full resource
result = self._run_passbolt(['get', '--id', resource_id, '--json'])
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
raise PassboltError(f"Failed to parse Passbolt response")
# Extract fields based on resource type
credential = PassboltCredential(
resource_id=resource_id,
name=data.get('name', ''),
username=data.get('username'),
password=data.get('password'),
uri=data.get('uri'),
description=data.get('description')
)
# Determine resource type
if 'resource_type' in data:
try:
credential.resource_type = PassboltResourceType(
data['resource_type'])
except ValueError:
pass # Keep default
return credential
def get_field(self, resource_id: str, field: str) -> str:
"""Get a specific field from a resource.
Args:
resource_id: UUID of the Passbolt resource
field: Field name (e.g., 'password', 'username', 'uri')
Returns:
Field value as string
"""
result = self._run_passbolt(
['get', '--id', resource_id, '--field', field])
return result.stdout.strip()
def get_password(self, resource_id: str) -> str:
"""Get just the password for a resource.
Args:
resource_id: UUID of the Passbolt resource
Returns:
Password string
"""
return self.get_field(resource_id, 'password')
def get_username(self, resource_id: str) -> str:
"""Get just the username for a resource.
Args:
resource_id: UUID of the Passbolt resource
Returns:
Username string
"""
return self.get_field(resource_id, 'username')
def list_resources(self, folder_id: Optional[str] = None,
search: Optional[str] = None) -> List[PassboltResource]:
"""List available resources.
Args:
folder_id: Optional folder ID to filter by
search: Optional search term
Returns:
List of PassboltResource objects
"""
args = ['list', '--json']
if folder_id:
args.extend(['--folder', folder_id])
if search:
args.extend(['--filter', search])
result = self._run_passbolt(args)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return []
resources = []
for item in data:
resources.append(PassboltResource(
id=item['id'],
name=item.get('name', ''),
username=item.get('username'),
uri=item.get('uri'),
resource_type=item.get('resource_type', 'password'),
folder_parent_id=item.get('folder_parent_id'),
personal=item.get('personal', False)
))
return resources
def find_resource_by_name(self, name: str) -> Optional[PassboltResource]:
"""Find a resource by name.
Args:
name: Name of the resource to find
Returns:
First matching PassboltResource or None
"""
resources = self.list_resources(search=name)
for resource in resources:
if resource.name == name:
return resource
return None
def create_resource(self, name: str, username: str, password: str,
uri: Optional[str] = None,
description: Optional[str] = None,
folder_id: Optional[str] = None) -> str:
"""Create a new password resource.
Args:
name: Resource name
username: Username
password: Password
uri: Optional URI/URL
description: Optional description
folder_id: Optional folder to place resource in
Returns:
ID of created resource
"""
args = ['create', 'resource',
'--name', name,
'--username', username,
'--password', password]
if uri:
args.extend(['--uri', uri])
if description:
args.extend(['--description', description])
if folder_id:
args.extend(['--folder', folder_id])
result = self._run_passbolt(args)
# Parse the ID from output
# Output format: "Resource created: <id>"
for line in result.stdout.split('\n'):
if 'created' in line.lower() and ':' in line:
parts = line.split(':', 1)
if len(parts) == 2:
return parts[1].strip()
raise PassboltError("Failed to parse created resource ID")
def update_resource(self, resource_id: str,
name: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
uri: Optional[str] = None,
description: Optional[str] = None) -> None:
"""Update an existing resource.
Args:
resource_id: ID of resource to update
name: New name (optional)
username: New username (optional)
password: New password (optional)
uri: New URI (optional)
description: New description (optional)
"""
args = ['update', 'resource', '--id', resource_id]
if name:
args.extend(['--name', name])
if username:
args.extend(['--username', username])
if password:
args.extend(['--password', password])
if uri:
args.extend(['--uri', uri])
if description:
args.extend(['--description', description])
self._run_passbolt(args)
def delete_resource(self, resource_id: str) -> None:
"""Delete a resource.
Args:
resource_id: ID of resource to delete
"""
self._run_passbolt(['delete', 'resource', '--id', resource_id])
def share_resource(self, resource_id: str, user_id: str,
permission: str = "read") -> None:
"""Share a resource with another user.
Args:
resource_id: ID of resource to share
user_id: ID of user to share with
permission: Permission level ('read', 'update', 'owner')
"""
self._run_passbolt([
'share', 'resource',
'--id', resource_id,
'--user', user_id,
'--permission', permission
])
def list_folders(self) -> List[Dict[str, Any]]:
"""List all folders.
Returns:
List of folder dictionaries
"""
result = self._run_passbolt(['list', 'folder', '--json'])
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return []
def get_folder_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""Find a folder by name.
Args:
name: Folder name to search for
Returns:
Folder dictionary or None
"""
folders = self.list_folders()
for folder in folders:
if folder.get('name') == name:
return folder
return None
def validate_resource_id(self, resource_id: str) -> bool:
"""Check if a resource ID exists and is accessible.
Args:
resource_id: UUID of the resource
Returns:
True if resource exists and is accessible
"""
try:
self._run_passbolt(['get', '--id', resource_id, '--field', 'name'])
return True
except PassboltError:
return False

311
services/vpn_manager.py Normal file
View File

@@ -0,0 +1,311 @@
"""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