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

101
CLAUDE.md
View File

@@ -32,19 +32,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**main.py** - Main GUI application entry point **main.py** - Main GUI application entry point
- `VPNManagerWindow` class: Primary PyGObject/GTK3-based GUI application - `VPNManagerWindow` class: Primary PyGObject/GTK3-based GUI application
- Implements single-view layout with Gtk.Stack for smooth transitions - Two-column layout: active customers (left) vs inactive customers (right)
- Features system tray integration using `pystray` - Features system tray integration using `pystray`
- Uses GNOME-style theming with CSS styling for cards - Uses GNOME-style theming with CSS card styling
- Includes advanced search functionality with wildcard support (`*`) - Includes comprehensive logging system with collapsible log view
- HeaderBar for native GNOME look and feel - HeaderBar for native GNOME look and feel
- Current location tracking and display - Current location tracking and enhanced display with network topology
**models.py** - Type-safe data model definitions using dataclasses and enums **models.py** - Type-safe data model definitions using dataclasses and enums
- `ServiceType`: Enum for service types (SSH, Web GUI, RDP, VNC, SMB, Database, FTP) - `ServiceType`: Enum for service types (SSH, Web GUI, RDP, VNC, SMB, Database, FTP)
- `HostType`: Enum for host types (Linux, Windows, Windows Server, Proxmox, ESXi, Router, Switch) - `HostType`: Enum for host types (Linux, Windows, Windows Server, Proxmox, ESXi, Router, Switch)
- `VPNType`: Enum for VPN types (OpenVPN, WireGuard, IPSec) - `VPNType`: Enum for VPN types (OpenVPN, WireGuard, IPSec)
- `Service`: Individual services on hosts with type-safe enums and port numbers - `Service`: Individual services on hosts with type-safe enums and port numbers
- `Host`: Physical/virtual machines with services and recursive sub-hosts (VMs) - `Host`: Physical/virtual machines with multiple IP addresses, services, and recursive sub-hosts (VMs)
- `Location`: Customer locations with VPN configurations and host infrastructure - `Location`: Customer locations with VPN configurations and host infrastructure
- `CustomerService`: Customer's cloud/web services (O365, CRM, etc.) - `CustomerService`: Customer's cloud/web services (O365, CRM, etc.)
- `Customer`: Top-level entities containing services and locations - `Customer`: Top-level entities containing services and locations
@@ -66,8 +66,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**widgets/** - Modular UI components using PyGObject **widgets/** - Modular UI components using PyGObject
- `customer_card.py`: `ActiveCustomerCard` and `InactiveCustomerCard` classes - `customer_card.py`: `ActiveCustomerCard` and `InactiveCustomerCard` classes
- Active cards: Interactive buttons for customer services and full location details - **Compact tree-like design**: Hierarchical layout with expand/collapse arrows
- Inactive cards: Read-only service lists and location activation buttons - **Card styling**: Customer cards contain location subcards with proper visual hierarchy
- **Multi-column layout**: Fixed-width columns for proper alignment (name, IP, actions)
- **Service action icons**: Direct access buttons for SSH, RDP, Web GUI with tooltips
- **Multiple IP support**: Display primary IP with hover tooltip showing all addresses
- Active cards: Full interaction with connection controls and infrastructure details
- Inactive cards: Activation buttons and current location setting
- `location_card.py`: `ActiveLocationCard` and `InactiveLocationCard` classes - `location_card.py`: `ActiveLocationCard` and `InactiveLocationCard` classes
- Active cards: Connection controls, deactivation (X button), and infrastructure details - Active cards: Connection controls, deactivation (X button), and infrastructure details
- Inactive cards: Current location setting and activation buttons - Inactive cards: Current location setting and activation buttons
@@ -76,6 +81,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Service buttons for direct access to SSH, Web GUI, RDP services - Service buttons for direct access to SSH, Web GUI, RDP services
- `__init__.py`: Widget exports for clean imports - `__init__.py`: Widget exports for clean imports
**services/** - VPN and credential management (modular architecture)
- `vpn_manager.py`: NetworkManager (nmcli) integration with .ovpn file support
- `passbolt_client.py`: Passbolt CLI client for secure credential management
- `connection_manager.py`: High-level orchestrator combining VPN and credentials
- Support for flexible credential storage (direct username/password or Passbolt UUIDs)
**views/** - Comprehensive logging system
- `log_view.py`: `LogView` class with collapsible interface
- **Command logging**: Real-time capture of nmcli and system command output
- **Color-coded levels**: Info, success, warning, error with visual distinction
- **Auto-scroll**: Automatic scrolling to latest entries with manual override
- **Expandable/collapsible**: Bottom panel that can be hidden to save space
**Configuration Files** **Configuration Files**
- `init_config.py`: Helper script to initialize user configuration with examples - `init_config.py`: Helper script to initialize user configuration with examples
- `example_customer.yaml`: Complete example showing YAML schema with all features - `example_customer.yaml`: Complete example showing YAML schema with all features
@@ -93,13 +111,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Active/Inactive**: Locations can be activated for VPN management - **Active/Inactive**: Locations can be activated for VPN management
- **Current Location**: User's physical location (separate from VPN connections) - **Current Location**: User's physical location (separate from VPN connections)
- **Connection State**: VPN connection status independent of location activation - **Connection State**: VPN connection status independent of location activation
- **Network Topology**: Each location includes internal networks and external endpoints
- **Credential Management**: Flexible credential storage (direct or Passbolt UUID)
- Automatic UI updates based on state changes with immediate feedback - Automatic UI updates based on state changes with immediate feedback
**Single-View UI Architecture with Stack Navigation**: **Two-Column Layout Architecture**:
- Uses `Gtk.Stack` for smooth view transitions with crossfade animation - **Left column**: Active customers with full location details and infrastructure
- **Normal mode**: Shows only active locations (full detail view) - **Right column**: Inactive customers available for activation
- **Search mode**: Shows only inactive locations (activation and current location setting) - **Compact design**: Tree-like hierarchy with proper indentation and alignment
- Clean visual separation with no overlapping or confusing dual-column layouts - **Real-time filtering**: Search affects both columns simultaneously
- **Dynamic reorganization**: Customers move between columns based on location state
**Widget-Based Component System**: **Widget-Based Component System**:
- Modular widget classes handle their own GTK widget creation and event handling - Modular widget classes handle their own GTK widget creation and event handling
@@ -118,7 +139,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
The application tracks two distinct location concepts: The application tracks two distinct location concepts:
- **Current Location**: Where the user physically is (set via "Set as Current" button) - **Current Location**: Where the user physically is (set via "Set as Current" button)
- **Active Locations**: Locations available for VPN connections - **Active Locations**: Locations available for VPN connections
- Current location is displayed prominently above the search bar
**Enhanced Current Location Display**:
- **Prominent info box** with customer name, location, and VPN type
- **Host count summary** with VM breakdown (e.g., "3 hosts (7 total with VMs)")
- **Collapsible infrastructure section** with detailed host and VM information
- **Network topology display**: Internal networks and external endpoints
- **Visual host type icons** (🐧 Linux, 🪟 Windows, 📦 Proxmox, 🌐 Router, etc.)
- **Hierarchical VM display** with service counts and multiple IP addresses
- **Multi-interface support**: Hosts can have multiple IP addresses (firewalls, routers)
- Users can set current location from inactive location cards without activating VPN - Users can set current location from inactive location cards without activating VPN
### Search and Discovery Features ### Search and Discovery Features
@@ -142,16 +171,24 @@ The application tracks two distinct location concepts:
### UI Layout Structure ### UI Layout Structure
**Modern Single-View Design**: **Modern Two-Column Design**:
- HeaderBar with title and subtitle (GNOME HIG compliance) - HeaderBar with title and current location display
- Current location display (centered, prominent) - **Enhanced current location info box** with network topology and collapsible infrastructure
- Search entry with comprehensive placeholder text (supports `*` wildcard) - Search entry with real-time filtering across both columns
- Single-view layout using Gtk.Stack for smooth transitions - **Left column**: Active customers with full interaction (connections, services, infrastructure)
- **Normal mode**: Active locations with full interaction (connections, services, infrastructure) - **Right column**: Inactive customers with activation and current location setting
- **Search mode**: Inactive locations with activation and current location setting - **Compact tree-like cards** with customer cards containing location subcards
- **Fixed-width columns**: Proper alignment of host names, IP addresses, and action icons
- **Collapsible log view**: Bottom panel for command output and system logs
- GNOME-style cards with CSS theming, proper spacing, and visual hierarchy - GNOME-style cards with CSS theming, proper spacing, and visual hierarchy
- System tray integration for minimize-to-tray behavior - System tray integration for minimize-to-tray behavior
**Customer Card Features**:
- **Active cards**: Start expanded, show full location details and services
- **Inactive cards**: Start collapsed to save space during search
- **Location count badges**: Show number of locations in parentheses
- **Smooth expand/collapse**: Click arrow buttons to toggle content visibility
### GTK3/PyGObject Specific Features ### GTK3/PyGObject Specific Features
- **CSS styling**: GNOME-style cards with borders, shadows, and adaptive theming - **CSS styling**: GNOME-style cards with borders, shadows, and adaptive theming
@@ -188,12 +225,28 @@ locations:
- name: Location Name - name: Location Name
vpn_type: OpenVPN|WireGuard|IPSec vpn_type: OpenVPN|WireGuard|IPSec
vpn_config: /path/to/config/file vpn_config: /path/to/config/file
active: true|false # Available for VPN management
connected: true|false # Current VPN connection status # VPN credentials (three options):
# Option 1: Dictionary with username/password
vpn_credentials:
username: vpnuser
password: password123
# Option 2: Passbolt UUID (for future implementation)
# vpn_credentials: "550e8400-e29b-41d4-a716-446655440000"
# Option 3: Omit or set to null if no credentials needed
# vpn_credentials: null
# Note: active and connected are runtime state (not stored in config)
# Network topology information
external_addresses: [vpn.domain.com, backup.domain.com] # VPN endpoints
networks: [192.168.1.0/24, 10.0.1.0/24] # Internal networks
hosts: hosts:
- name: Host Name - name: Host Name
ip_address: IP Address ip_addresses: [192.168.1.10, 10.0.1.10] # Multiple interfaces supported
host_type: Linux|Windows|Windows Server|Proxmox|ESXi|Router|Switch host_type: Linux|Windows|Windows Server|Proxmox|ESXi|Router|Switch
description: Optional description description: Optional description
@@ -204,7 +257,7 @@ locations:
sub_hosts: # Optional VMs/containers (recursive structure) sub_hosts: # Optional VMs/containers (recursive structure)
- name: VM Name - name: VM Name
ip_address: VM IP ip_addresses: [192.168.1.20] # VMs can also have multiple IPs
host_type: Linux|Windows|Windows Server host_type: Linux|Windows|Windows Server
services: # Same structure as parent host services: # Same structure as parent host
- name: Service Name - name: Service Name

1
assets/icons/debian.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="50.32 0 411.28 512.1"><style>.st0{fill:#a80030}</style><path d="M296.2 270.5c-8.5.1 1.6 4.4 12.7 6.1 3.1-2.4 5.8-4.8 8.3-7.2-7 1.7-14 1.8-21 1.1m45.5-11.3c5.1-7 8.7-14.6 10-22.5-1.1 5.6-4.2 10.5-7.1 15.6-15.9 10-1.5-5.9 0-12-17 21.5-2.3 12.9-2.9 18.9m16.8-43.8c1-15.3-3-10.4-4.4-4.6 1.6.8 2.9 10.8 4.4 4.6M264 6.6c4.5.8 9.8 1.4 9 2.5 5-1.1 6.1-2.1-9-2.5m9 2.5-3.2.7 3-.3z" class="st0"/><path d="M414.2 221.2c.5 13.7-4 20.4-8.1 32.2l-7.3 3.7c-6 11.7.6 7.4-3.7 16.7-9.4 8.3-28.4 26.1-34.5 27.7-4.5-.1 3-5.3 4-7.3-12.5 8.6-10.1 12.9-29.2 18.2l-.6-1.2c-47.3 22.3-113-21.8-112.1-82-.5 3.8-1.4 2.9-2.5 4.4-2.4-31 14.3-62 42.5-74.7 27.6-13.7 60-8.1 79.8 10.4-10.9-14.2-32.5-29.3-58.1-27.9-25.1.4-48.6 16.3-56.4 33.7-12.9 8.1-14.4 31.2-20 35.4-7.5 55.4 14.2 79.4 50.9 107.5 5.8 3.9 1.6 4.5 2.4 7.5-12.2-5.7-23.4-14.3-32.6-24.9 4.9 7.1 10.1 14.1 16.9 19.5-11.5-3.9-26.9-27.9-31.4-28.9 19.8 35.5 80.5 62.3 112.3 49-14.7.5-33.4.3-49.9-5.8-6.9-3.6-16.4-11-14.7-12.3 43.3 16.2 88.1 12.3 125.6-17.8 9.5-7.4 20-20.1 23-20.2-4.5 6.8.8 3.3-2.7 9.3 9.5-15.3-4.1-6.2 9.8-26.5l5.2 7.1c-1.9-12.7 15.8-28.2 14-48.3 4.1-6.2 4.5 6.6.2 20.8 6-15.7 1.6-18.2 3.1-31.2 1.7 4.4 3.8 9 5 13.6-3.9-15.2 4-25.6 6-34.4-1.9-.9-6 6.7-7-11.2.1-7.8 2.2-4.1 3-6-1.5-.9-5.5-6.9-8-18.3 1.8-2.7 4.7 7 7.1 7.4-1.5-9.1-4.2-16.1-4.3-23-7-14.7-2.5 2-8.2-6.3-7.5-23.4 6.2-5.4 7.1-16 11.3 16.4 17.8 41.9 20.8 52.5-2.3-12.9-5.9-25.3-10.4-37.4 3.4 1.4-5.6-26.5 4.5-8-10.7-39.4-45.9-76.3-78.2-93.6 4 3.6 9 8.2 7.2 8.9-16.1-9.6-13.3-10.3-15.6-14.4-13.1-5.3-14 .4-22.6 0-24.7-13.5-29.4-12.1-52.2-20.3l1 4.8c-16.4-5.4-19.1 2.1-36.7 0-1.1-.8 5.7-3 11.2-3.8-15.8 2.1-15.1-3.1-30.6.6C243 7.7 247 6 251.1 3.7c-12.9.8-30.8 7.5-25.3 1.4-21 9.4-58.4 22.6-79.4 42.2l-.7-4.4c-9.6 11.5-41.9 34.4-44.5 49.4l-2.6.6c-5 8.5-8.2 18.1-12.2 26.8-6.5 11.1-9.6 4.3-8.7 6-12.9 26.1-19.2 48-24.8 66 3.9 5.9.1 35.4 1.6 59-6.5 116.6 81.9 229.9 178.4 256 14.1 5.1 35.2 4.9 53.1 5.4-21.1-6-23.8-3.2-44.4-10.4-14.8-7-18.1-15-28.6-24.1l4.2 7.4c-20.6-7.3-12-9-28.8-14.3l4.4-5.8c-6.7-.5-17.7-11.3-20.7-17.2l-7.3.3c-8.8-10.8-13.5-18.6-13.1-24.7l-2.4 4.2c-2.7-4.6-32.3-40.6-16.9-32.2-2.9-2.6-6.7-4.2-10.8-11.7l3.1-3.6c-7.4-9.5-13.6-21.7-13.1-25.8 3.9 5.3 6.7 6.3 9.4 7.2-18.7-46.4-19.7-2.6-33.9-47.2l3-.2c-2.3-3.5-3.7-7.2-5.5-10.9l1.3-13c-13.5-15.6-3.8-66.1-1.8-93.9 1.3-11.3 11.2-23.3 18.7-42.1l-4.6-.8c8.8-15.3 50-61.3 69.1-58.9 9.2-11.6-1.8 0-3.6-3 20.3-21 26.7-14.9 40.4-18.6 14.8-8.8-12.7 3.4-5.7-3.3 25.6-6.5 18.1-14.8 51.5-18.2 3.5 2-8.2 3.1-11.1 5.7 21.3-10.4 67.4-8 97.3 5.8 34.7 16.2 73.8 64.2 75.3 109.4l1.8.5c-.9 17.9 2.7 38.7-3.6 57.8z" class="st0"/><path d="m203.6 282.2-1.2 5.9c5.6 7.6 10 15.8 17.1 21.7-5.1-10-8.9-14.1-15.9-27.6m13.1-.6c-3-3.3-4.7-7.2-6.7-11.1 1.9 6.9 5.7 12.8 9.3 18.8zM450 230.9l-1.2 3.1c-2.3 16.2-7.2 32.3-14.8 47.2 8.3-15.7 13.7-32.9 16-50.3M265.7 2.5c5.7-2.1 14.1-1.2 20.2-2.5-7.9.7-15.8 1.1-23.6 2.1zM64.3 109.6c1.3 12.2-9.2 17 2.3 8.9 6.2-13.9-2.4-3.8-2.3-8.9m-13.6 56.7c2.7-8.2 3.1-13.1 4.2-17.8-7.4 9.4-3.4 11.4-4.2 17.8" class="st0"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

1
assets/icons/docker.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="-0.07 71.3 511.97 369.5"><path d="M501.4 212.3c-11.5-8-38-11-58.6-7-2.4-20-13.5-37.5-32.7-53l-11-8-7.7 11.5c-9.6 15-14.4 36-13 56 .5 7 2.9 19.5 10.1 30.5-6.7 4-20.7 9-38.9 9H2.3l-1 4c-3.4 20-3.4 82.5 36 130.5 29.8 36.5 74 55 132.1 55 125.9 0 219.1-60.5 262.8-170 17.3.5 54.3 0 73-37.5.5-1 1.4-3 4.8-10.5l1.9-4zM280 71.3h-52.8v50H280zm0 60h-52.8v50H280zm-62.5 0h-52.8v50h52.8zm-62.4 0h-52.8v50h52.8zm-62.5 60H39.8v50h52.8zm62.5 0h-52.8v50h52.8zm62.4 0h-52.8v50h52.8zm62.5 0h-52.8v50H280zm62.4 0h-52.8v50h52.8z" style="fill:#2396ed"/></svg>

After

Width:  |  Height:  |  Size: 608 B

1
assets/icons/esxi.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><path fill="#78be20" d="M384 68.078v106.413h16V68.078A68.156 68.156 0 0 0 331.922 0H224.044l-16 16h123.878A52.137 52.137 0 0 1 384 68.078"/><path fill="#00c1d5" d="M172.738 384H68.078A52.14 52.14 0 0 1 16 331.922V68.078A52.14 52.14 0 0 1 68.078 16h104.565l16-16H68.078A68.156 68.156 0 0 0 0 68.078v263.844A68.156 68.156 0 0 0 68.078 400h104.66Z"/><path fill="#0091da" d="M62.844 78.27V255.8a15.46 15.46 0 0 0 15.44 15.442h22.332v16H78.284a31.476 31.476 0 0 1-31.44-31.442V78.27a31.476 31.476 0 0 1 31.44-31.44H251.5a31.476 31.476 0 0 1 31.44 31.44v18.054h-16V78.27a15.457 15.457 0 0 0-15.44-15.44H78.284a15.457 15.457 0 0 0-15.44 15.44m84.705 274.374h25.189v-16h-25.189a15.457 15.457 0 0 1-15.441-15.44V143.673a15.46 15.46 0 0 1 15.441-15.441H320.76a15.457 15.457 0 0 1 15.44 15.441v30.818h16v-30.818a31.476 31.476 0 0 0-31.44-31.441H147.549a31.476 31.476 0 0 0-31.441 31.441V321.2a31.476 31.476 0 0 0 31.441 31.444"/><path fill="#1d428a" d="M247.125 294.54a45.974 45.974 0 1 1 45.975-45.974 46.026 46.026 0 0 1-45.975 45.974m0-75.948a29.974 29.974 0 1 0 29.975 29.974 30.01 30.01 0 0 0-29.975-29.974m106.082 182.031a45.974 45.974 0 1 1 45.975-45.975 46.026 46.026 0 0 1-45.975 45.975m0-75.948a29.974 29.974 0 1 0 29.975 29.973 30.01 30.01 0 0 0-29.975-29.973m45.92-32.033h-88.15v-88.151h88.15Zm-72.15-16h56.15v-56.151h-56.15ZM290.889 398.88h-88.151v-88.151h88.151Zm-72.151-16h56.151v-56.151h-56.151Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 8 55 48"><path fill="#28a8ea" d="M55.51 8H43.303a3.5 3.5 0 0 0-2.468 1.022L12.022 37.835A3.5 3.5 0 0 0 11 40.303V52.51A3.49 3.49 0 0 0 14.49 56h12.207a3.5 3.5 0 0 0 2.468-1.022l28.813-28.813A3.5 3.5 0 0 0 59 23.697V11.49A3.49 3.49 0 0 0 55.51 8"/><path fill="#0078d4" d="M55.51 56H43.303a3.5 3.5 0 0 1-2.468-1.022L35 49.143V38.24A6.24 6.24 0 0 1 41.24 32h10.903l5.835 5.835A3.5 3.5 0 0 1 59 40.303V52.51A3.49 3.49 0 0 1 55.51 56"/><path fill="#50d9ff" d="M14.49 8h12.207a3.5 3.5 0 0 1 2.468 1.022L35 14.857V25.76A6.24 6.24 0 0 1 28.76 32H17.857l-5.835-5.835A3.5 3.5 0 0 1 11 23.697V11.49A3.49 3.49 0 0 1 14.49 8"/><path d="M33 20.33v26.34a1.7 1.7 0 0 1-.04.4A2.314 2.314 0 0 1 30.67 49H11V18h19.67A2.326 2.326 0 0 1 33 20.33" opacity=".2"/><path d="M34 20.33v24.34A3.36 3.36 0 0 1 30.67 48H11V17h19.67A3.34 3.34 0 0 1 34 20.33" opacity=".1"/><path d="M33 20.33v24.34A2.326 2.326 0 0 1 30.67 47H11V18h19.67A2.326 2.326 0 0 1 33 20.33" opacity=".2"/><path d="M32 20.33v24.34A2.326 2.326 0 0 1 29.67 47H11V18h18.67A2.326 2.326 0 0 1 32 20.33" opacity=".1"/><rect width="28" height="28" x="4" y="18" fill="#0078d4" rx="2.333"/><path fill="#fff" d="M22.585 26.881h-6.547v3.829h6.145v2.454h-6.145v3.976h6.896v2.443h-9.868V24.417h9.52Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

31
assets/icons/opnsense.svg Normal file
View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<image width="200" height="200" display="none" image-rendering="optimizeSpeed" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAFeklEQVR4nOzdP48VVRjA4VlCQgIm
2EChhSQWGgpzGxtiAvoBoLQE1I5CKNhSxUr3FrLFlrhraedSWemSGBobYiIGE+NujERZGwtJMDHX
XMJNXCCv98/MnHNmnucDjKfwl/Mezuzc/aPRqCrF7tebx29/8M73qdfRN4cHJ64OVjcvpV5HCvtS
L4D8/Xnr5sXb77+9knodKQiEqexuXb98690zn6ReR9sEwtT6uJMIhJn0bScRCDMb7yR9iUQgzKUv
45ZAmFsfxi2BsJCuj1sCYWFdHrcEQi26Om4JhNp0cScRCLXq2k4iEGrXpYO7QGhEV8YtgdCYLoxb
AqFR451ke33lbOp1zEsgNG5nY7hWaiQCoQ2HdjaGGyWOWwKhNSUe3AVCq0o7uAuE1pV0TyIQkihl
3BIIyZQwbgmEpHK/JxEIyeV8TyIQcpDtPYlAyEaOB3eBkJXcDu4CITs53ZMIhCzlMm4JhGzlMG4J
hKyl3kkEQvZS7iQCoQipDu4CoRgpxi2BUJS2xy2BUJw2X3AUCEXa2RiutTFuCYRSHWpj3BIIRWv6
4C4QitfkTiIQOqGpe5Kl37/6YvDH1vUzdT+4CQ/u3T144Ohzz6ReB/P5a/vOy/e377zR5H/jyKnT
w+NXri3X9bylnz/9+NzOxnC9rgc2bPfkjd2jqRfBfLbXV1r5f+3w4MTVwermpTqeZcSic+q8JxEI
nVTXhyAEQlfV8iEIgdBpi96TCITOW+SeRCD0wrz3JAKhN+YZtwRCr8w6bgmE3pllJxEIvTTtTiIQ
emuag7tA6LX/G7cEQu9F45ZAIBi3BAKPPG3cEgj8x+PjlkDgMY92ko8qgcDT7W5df6sSCMQEAgGB
QEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBA
IBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQ
EAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAI
BAQCAYFAQCAQEAgEBAIBgUBAIBAQCATGgfyWehGQq33Hzi9/efDYSxupFwI5ejhivTL8/IJI4EkP
Azlw9Pn7r372zXmRwF57Dul2EthrTyCTneTw4MTFdEuCfDz1n3kHq5urdhII7kGMWxAEYtyCKW7S
jVv02VSvmhi36KupAnFPQl/N9LKinYS+mSkQB3f6Zq7X3R3c6Yu5/x7EuEUfzB2IcYs+WPgvCsfj
1pFTp5frWQ7kpZY/uT1+5drQTkIXLY1Go9oe9u3Z19bvb985V9sDn/RPVVW/NPh8mvXDC+cur6Re
xJT+PnZ++WatgTy49+vB7y6/udZwJJRr6+SN3ddTL2IWtX7VxMGdrmnksz/uSeiKxr6L5Z6ELmgs
EOMWXdD4lxXdk1CyVj496p6EUrX2bV4Hd0rU6serHdwpTauBOLhTmiQ/f2DcohTJfh/EuEUJkgXi
QxCUIPkvTNlJyFnyQBzcyVnyQCYc3MlRNoFUxi0ylFUgxi1yk1UgE15wJBdZBjL24oUP14xbpJZt
IO5JyEG2gUw4uJNS9oE4uJNS9oFMuCchhWICqYxbJFBUIMYt2lZUIBPuSWhLkYFUPgRBS4oNpHJw
pwVFB1I5uNOw4gNxcKdJxQcyYdyiCZ0JpDJu0YBOBeIFR+rWqUAm7CTUpZOBOLhTl04GMuHgzqI6
HUhl3GJBnQ/EuMUiOh/IhHGLefQmkMq4xRx6FYh7Ema1P/UCUhjvJD+tvXe3qqpnU6+lZ35MvYBZ
LY1Go9RrgGz9GwAA//8/Z0LNgEyjNwAAAABJRU5ErkJggg==
"/>
<path d="m44 0v44h112v112h44v-92.93l-63.1-63.07zm112 156h-112v-112h-44v92l64 64h92z" fill="#c03e14"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

1
assets/icons/proxmox.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 34 512 444"><path d="M137.9 34.1c-10.5 0-19.7 1.9-28.5 5.7-8.6 3.8-16.2 8.9-22.9 15.6l170 186.4L426.1 55.3c-6.7-6.7-14.3-11.8-23.4-15.6-8.3-3.8-18-5.7-28-5.7-10.5 0-20.5 2.2-29.4 6.2-9.2 4-16.7 10-23.7 17l-65.2 72.2-66-72.2c-6.7-7-14.3-12.9-23.7-17-8.3-4-18.3-6.1-28.8-6.1M256.4 270l-170 186.7c6.7 6.5 14.3 11.8 22.9 15.6 8.9 3.8 18.1 5.7 28 5.7 11 0 20.5-2.4 29.4-6.2 9.4-4.3 17.5-10 24.2-17l65.5-72.2 65.4 72.2c6.7 7 14.3 12.7 23.4 17 8.9 3.8 18.6 6.2 29.4 6.2 10 0 19.7-1.9 28-5.7 9.2-3.8 16.7-9.2 23.4-15.6z" style="fill-rule:evenodd;clip-rule:evenodd"/><path d="M56 90.1c-10.8.3-21.3 2.4-30.7 6.5-9.7 4-18 9.7-25.3 16.7L129.8 256 0 398.5c7.3 7.3 15.6 12.9 25.3 17.2 9.4 4.3 19.9 6.2 30.7 6.7 11.6-.5 22.4-2.4 32.3-7.3q15-6.9 25.8-18.6l128-140.5-127.9-140.3c-7.8-7.5-16.2-13.7-26.1-18.6-10-4.6-20.5-6.7-32.1-7m399.7 0c-11.6.3-21.8 2.4-31.8 7-10 4.8-18.6 11-26.1 18.6L270.4 256l127.4 140.6q11.25 11.7 26.1 18.6c10 4.8 20.2 6.7 31.8 7.3 11.6-.5 21.5-2.4 31-6.7 10.2-4.3 18-10 25.3-17.2L382.5 256 512 113.3c-7.3-7-15.1-12.7-25.3-16.7-9.4-4.1-19.4-6.2-31-6.5" style="fill-rule:evenodd;clip-rule:evenodd;fill:#e57000"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
assets/icons/windows-11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

BIN
customer_card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -2,7 +2,7 @@ import yaml
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any from typing import List, Dict, Any
from models import ( from models import (
Customer, CustomerService, Location, Host, Service, Customer, CustomerService, Location, Host, Service, NetworkSegment, HostIP, PortForwarding,
ServiceType, HostType, VPNType ServiceType, HostType, VPNType
) )
@@ -73,11 +73,42 @@ def parse_host(host_data: Dict[str, Any]) -> Host:
) )
services.append(service) services.append(service)
# Parse IP addresses - handle both new HostIP format and legacy formats
ip_addresses = []
if 'ip_addresses' in host_data:
for ip_data in host_data['ip_addresses']:
if isinstance(ip_data, dict):
# New HostIP format: {ip_address: "192.168.1.10", network_segment: "LAN", is_primary: true}
host_ip = HostIP(
ip_address=ip_data['ip_address'],
network_segment=ip_data.get(
'network_segment', 'LAN'), # Default segment
is_primary=ip_data.get('is_primary', False)
)
ip_addresses.append(host_ip)
else:
# Legacy format: simple string list
host_ip = HostIP(
ip_address=ip_data,
network_segment='LAN', # Default segment for legacy format
is_primary=len(ip_addresses) == 0 # First IP is primary
)
ip_addresses.append(host_ip)
elif 'ip_address' in host_data:
# Very old format: single IP string
host_ip = HostIP(
ip_address=host_data['ip_address'],
network_segment='LAN',
is_primary=True
)
ip_addresses.append(host_ip)
# Create host # Create host
host = Host( host = Host(
name=host_data['name'], name=host_data['name'],
ip_address=host_data['ip_address'], ip_addresses=ip_addresses,
host_type=parse_host_type(host_data['host_type']), host_type=parse_host_type(host_data['host_type']),
icon=host_data.get('icon'), # Custom icon name
description=host_data.get('description', ''), description=host_data.get('description', ''),
services=services services=services
) )
@@ -93,6 +124,34 @@ def parse_host(host_data: Dict[str, Any]) -> Host:
def parse_location(location_data: Dict[str, Any]) -> Location: def parse_location(location_data: Dict[str, Any]) -> Location:
"""Parse a location from YAML data.""" """Parse a location from YAML data."""
# Parse network segments
network_segments = []
if 'network_segments' in location_data:
for segment_data in location_data['network_segments']:
segment = NetworkSegment(
name=segment_data['name'],
cidr=segment_data['cidr'],
vlan_id=segment_data.get('vlan_id'),
zone=segment_data.get('zone', 'general'),
gateway=segment_data.get('gateway'),
description=segment_data.get('description', '')
)
network_segments.append(segment)
# Parse port forwardings
port_forwardings = []
if 'port_forwardings' in location_data:
for pf_data in location_data['port_forwardings']:
port_forward = PortForwarding(
external_port=pf_data['external_port'],
internal_ip=pf_data['internal_ip'],
internal_port=pf_data['internal_port'],
protocol=pf_data.get('protocol', 'tcp'),
description=pf_data.get('description', ''),
enabled=pf_data.get('enabled', True)
)
port_forwardings.append(port_forward)
# Parse hosts # Parse hosts
hosts = [] hosts = []
if 'hosts' in location_data: if 'hosts' in location_data:
@@ -108,6 +167,10 @@ def parse_location(location_data: Dict[str, Any]) -> Location:
active=False, # Runtime state - always starts inactive active=False, # Runtime state - always starts inactive
vpn_config=location_data.get('vpn_config', ''), vpn_config=location_data.get('vpn_config', ''),
hosts=hosts, hosts=hosts,
network_segments=network_segments,
networks=location_data.get('networks', []), # Legacy support
external_addresses=location_data.get('external_addresses', []),
port_forwardings=port_forwardings,
vpn_credentials=location_data.get('vpn_credentials'), vpn_credentials=location_data.get('vpn_credentials'),
nmcli_connection_name=location_data.get('nmcli_connection_name'), nmcli_connection_name=location_data.get('nmcli_connection_name'),
auto_import=location_data.get('auto_import', True) auto_import=location_data.get('auto_import', True)
@@ -204,20 +267,64 @@ def save_customer(customer: Customer, filename: str = None) -> None:
# Convert locations # Convert locations
for location in customer.locations: for location in customer.locations:
# Convert network segments
network_segments = []
for segment in location.network_segments:
segment_data = {
'name': segment.name,
'cidr': segment.cidr,
'zone': segment.zone,
'description': segment.description
}
if segment.vlan_id is not None:
segment_data['vlan_id'] = segment.vlan_id
if segment.gateway is not None:
segment_data['gateway'] = segment.gateway
network_segments.append(segment_data)
# Convert port forwardings
port_forwardings = []
for pf in location.port_forwardings:
pf_data = {
'external_port': pf.external_port,
'internal_ip': pf.internal_ip,
'internal_port': pf.internal_port,
'protocol': pf.protocol,
'enabled': pf.enabled
}
if pf.description:
pf_data['description'] = pf.description
port_forwardings.append(pf_data)
location_data = { location_data = {
'name': location.name, 'name': location.name,
'vpn_type': location.vpn_type.value, 'vpn_type': location.vpn_type.value,
'vpn_config': location.vpn_config, 'vpn_config': location.vpn_config,
'active': location.active, 'network_segments': network_segments,
'connected': location.connected, 'external_addresses': location.external_addresses,
'port_forwardings': port_forwardings,
'hosts': [] 'hosts': []
} }
# Add legacy networks if they exist
if location.networks:
location_data['networks'] = location.networks
# Convert hosts # Convert hosts
def convert_host(host): def convert_host(host):
# Convert HostIP objects back to dictionaries
ip_addresses = []
for host_ip in host.ip_addresses:
ip_dict = {
'ip_address': host_ip.ip_address,
'network_segment': host_ip.network_segment,
'is_primary': host_ip.is_primary
}
ip_addresses.append(ip_dict)
host_data = { host_data = {
'name': host.name, 'name': host.name,
'ip_address': host.ip_address, 'ip_addresses': ip_addresses,
'host_type': host.host_type.value, 'host_type': host.host_type.value,
'description': host.description, 'description': host.description,
'services': [ 'services': [
@@ -230,6 +337,10 @@ def save_customer(customer: Customer, filename: str = None) -> None:
] ]
} }
# Add icon if specified
if host.icon:
host_data['icon'] = host.icon
if host.sub_hosts: if host.sub_hosts:
host_data['sub_hosts'] = [convert_host( host_data['sub_hosts'] = [convert_host(
subhost) for subhost in host.sub_hosts] subhost) for subhost in host.sub_hosts]
@@ -267,12 +378,22 @@ def get_demo_customers() -> List[Customer]:
vpn_type=VPNType.OPENVPN, vpn_type=VPNType.OPENVPN,
connected=False, connected=False,
active=True, active=True,
vpn_config="/etc/openvpn/demo.ovpn" vpn_config="demo.ovpn" # File in ~/.vpntray/vpn/
)
# Create a demo network segment
demo_segment = NetworkSegment(
name="LAN",
cidr="10.0.0.0/24",
gateway="10.0.0.1",
zone="production",
description="Demo network"
) )
demo_host = Host( demo_host = Host(
name="DEMO-01", name="DEMO-01",
ip_address="10.0.0.1", ip_addresses=[HostIP(ip_address="10.0.0.1",
network_segment="LAN", is_primary=True)],
host_type=HostType.LINUX, host_type=HostType.LINUX,
description="Demo server", description="Demo server",
services=[ services=[
@@ -282,6 +403,7 @@ def get_demo_customers() -> List[Customer]:
) )
demo_location.hosts = [demo_host] demo_location.hosts = [demo_host]
demo_location.network_segments = [demo_segment]
demo_customer.locations = [demo_location] demo_customer.locations = [demo_location]
return [demo_customer] return [demo_customer]
@@ -317,13 +439,24 @@ services:
locations: locations:
- name: Main Office - name: Main Office
vpn_type: WireGuard vpn_type: WireGuard
vpn_config: /etc/wireguard/simple.conf vpn_config: simple.conf # File in ~/.vpntray/vpn/
active: false
connected: false network_segments:
- name: LAN
cidr: 192.168.1.0/24
gateway: 192.168.1.1
zone: production
description: Main office network
external_addresses:
- simple.vpn.example.com
hosts: hosts:
- name: SERVER-01 - name: SERVER-01
ip_address: 192.168.1.10 ip_addresses:
- ip_address: 192.168.1.10
network_segment: LAN
is_primary: true
host_type: Linux host_type: Linux
description: Main server description: Main server
services: services:

View File

@@ -22,7 +22,72 @@ services:
locations: locations:
- name: Main Office - name: Main Office
vpn_type: OpenVPN vpn_type: OpenVPN
vpn_config: /etc/openvpn/techcorp-main.ovpn vpn_config: techcorp-main.ovpn # File in ~/.vpntray/vpn/
# External connection endpoints (can have multiple for redundancy)
external_addresses:
- vpn.techcorp.com # Primary VPN endpoint
- vpn2.techcorp.com # Backup endpoint
- 203.0.113.10 # Direct IP fallback
# Port forwarding rules for external access
port_forwardings:
- external_port: 8006
internal_ip: 192.168.1.10
internal_port: 8006
protocol: tcp
description: Proxmox web interface
enabled: true
- external_port: 3389
internal_ip: 192.168.1.20
internal_port: 3389
protocol: tcp
description: Domain Controller RDP
enabled: true
- external_port: 9000
internal_ip: 192.168.1.21
internal_port: 9000
protocol: tcp
description: File server web panel
enabled: true
- external_port: 5050
internal_ip: 192.168.1.22
internal_port: 5050
protocol: tcp
description: pgAdmin database interface
enabled: true
- external_port: 443
internal_ip: 192.168.1.1
internal_port: 443
protocol: tcp
description: Firewall web interface
enabled: true
# Network segments with rich metadata
network_segments:
- name: LAN
cidr: 192.168.1.0/24
gateway: 192.168.1.1
zone: production
description: Main office LAN
- name: Management
cidr: 10.0.1.0/24
vlan_id: 100
gateway: 10.0.1.1
zone: management
description: Out-of-band management network
- name: Services
cidr: 172.16.1.0/24
vlan_id: 200
gateway: 172.16.1.1
zone: production
description: Internal services network
# VPN credentials - three options: # VPN credentials - three options:
# Option 1: Dictionary with username/password # Option 1: Dictionary with username/password
@@ -39,8 +104,12 @@ locations:
# Hosts at this location # Hosts at this location
hosts: hosts:
- name: PVE-01 - name: PVE-01
ip_address: 192.168.1.10 ip_addresses:
- ip_address: 192.168.1.10
network_segment: LAN
is_primary: true
host_type: Proxmox host_type: Proxmox
icon: proxmox # Custom icon: assets/icons/proxmox.svg
description: Main virtualization server description: Main virtualization server
services: services:
- name: Web Interface - name: Web Interface
@@ -53,7 +122,10 @@ locations:
# VMs running on this host # VMs running on this host
sub_hosts: sub_hosts:
- name: DC-01 - name: DC-01
ip_address: 192.168.1.20 ip_addresses:
- ip_address: 192.168.1.20
network_segment: LAN
is_primary: true
host_type: Windows Server host_type: Windows Server
description: Domain Controller description: Domain Controller
services: services:
@@ -65,8 +137,12 @@ locations:
port: 8080 port: 8080
- name: FILE-01 - name: FILE-01
ip_address: 192.168.1.21 ip_addresses:
- ip_address: 192.168.1.21
network_segment: LAN
is_primary: true
host_type: Linux host_type: Linux
icon: ubuntu # Custom icon: assets/icons/ubuntu.svg
description: File Server (Samba) description: File Server (Samba)
services: services:
- name: SSH - name: SSH
@@ -80,9 +156,15 @@ locations:
port: 9000 port: 9000
- name: DB-01 - name: DB-01
ip_address: 192.168.1.22 ip_addresses:
- ip_address: 192.168.1.22
network_segment: LAN
is_primary: true
- ip_address: 172.16.1.22
network_segment: Services
is_primary: false
host_type: Linux host_type: Linux
description: PostgreSQL Database description: PostgreSQL Database (dual-homed)
services: services:
- name: SSH - name: SSH
service_type: SSH service_type: SSH
@@ -95,9 +177,19 @@ locations:
port: 5050 port: 5050
- name: FW-01 - name: FW-01
ip_address: 192.168.1.1 ip_addresses:
- ip_address: 192.168.1.1
network_segment: LAN
is_primary: true
- ip_address: 10.0.1.1
network_segment: Management
is_primary: false
- ip_address: 172.16.1.1
network_segment: Services
is_primary: false
host_type: Router host_type: Router
description: pfSense Firewall/Router icon: pfsense # Custom icon: assets/icons/pfsense.svg
description: pfSense Firewall/Router (multi-interface)
services: services:
- name: Web Interface - name: Web Interface
service_type: Web GUI service_type: Web GUI
@@ -107,9 +199,15 @@ locations:
port: 22 port: 22
- name: SW-01 - name: SW-01
ip_address: 192.168.1.2 ip_addresses:
- ip_address: 192.168.1.2
network_segment: LAN
is_primary: true
- ip_address: 10.0.1.2
network_segment: Management
is_primary: false
host_type: Switch host_type: Switch
description: Managed Switch description: Managed Switch (dual-homed)
services: services:
- name: Web Interface - name: Web Interface
service_type: Web GUI service_type: Web GUI
@@ -120,16 +218,57 @@ locations:
- name: Branch Office - name: Branch Office
vpn_type: WireGuard vpn_type: WireGuard
vpn_config: /etc/wireguard/techcorp-branch.conf vpn_config: techcorp-branch.conf # File in ~/.vpntray/vpn/
# External connection endpoints
external_addresses:
- 198.51.100.50 # Branch office static IP
- branch.techcorp.com # Dynamic DNS endpoint
# Port forwarding rules
port_forwardings:
- external_port: 8080
internal_ip: 10.10.1.10
internal_port: 8080
protocol: tcp
description: Branch web services
enabled: true
- external_port: 22
internal_ip: 10.10.1.10
internal_port: 22
protocol: tcp
description: SSH access to branch server
enabled: false # Disabled for security
# Network segments
network_segments:
- name: Branch_LAN
cidr: 10.10.1.0/24
gateway: 10.10.1.1
zone: production
description: Branch office network
- name: Local_Services
cidr: 192.168.100.0/24
gateway: 192.168.100.1
zone: general
description: Local branch services network
# No credentials needed for WireGuard (uses keys in config file) # No credentials needed for WireGuard (uses keys in config file)
vpn_credentials: null vpn_credentials: null
hosts: hosts:
- name: BRANCH-01 - name: BRANCH-01
ip_address: 10.10.1.10 ip_addresses:
- ip_address: 10.10.1.10
network_segment: Branch_LAN
is_primary: true
- ip_address: 192.168.100.1
network_segment: Local_Services
is_primary: false
host_type: Linux host_type: Linux
description: Branch office server description: Branch office server (dual-homed)
services: services:
- name: SSH - name: SSH
service_type: SSH service_type: SSH

472
main.py
View File

@@ -1,22 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from views import ActiveView, InactiveView from views import ActiveView, InactiveView, LogView
from data_loader import load_customers from data_loader import load_customers
from models import Customer from models import Customer
from PIL import Image, ImageDraw # from services import VPNManager, VPNStatus, VPNConnectionError # Temporarily disabled due to syntax errors
import pystray from services import VPNManager, VPNStatus, VPNConnectionError
import threading
import sys import sys
from gi.repository import Gtk, Gdk, GLib import logging
from gi.repository import Gtk, Gdk, GLib, Gio
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
class VPNManagerWindow: class VPNManagerWindow:
vpn_manager: VPNManager
def __init__(self): def __init__(self):
self.customers = load_customers() self.customers = load_customers()
self.filtered_customers = self.customers.copy() self.filtered_customers = self.customers.copy()
self.current_location = None # Track user's current location self.current_location = None # Track user's current location
# VPN manager will be initialized after UI setup
self.vpn_manager = None
# Create main window # Create main window
self.window = Gtk.Window() self.window = Gtk.Window()
self.window.set_title("VPN Manager") self.window.set_title("VPN Manager")
@@ -29,25 +34,13 @@ class VPNManagerWindow:
# Create UI # Create UI
self.setup_ui() self.setup_ui()
self.setup_system_tray() self.vpn_manager = VPNManager()
# Start hidden
self.window.hide()
def setup_css(self): def setup_css(self):
"""Minimal CSS for GNOME-style cards""" """Minimal CSS for GNOME-style cards"""
css_provider = Gtk.CssProvider() css_provider = Gtk.CssProvider()
css = """ css_provider.load_from_file(Gio.File.new_for_path('style.css'))
.card { # css_provider.load_from_data(css.encode())
background: @theme_base_color;
border-radius: 8px;
border: 1px solid @borders;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 16px;
margin: 6px;
}
"""
css_provider.load_from_data(css.encode())
# Apply CSS to default screen # Apply CSS to default screen
screen = Gdk.Screen.get_default() screen = Gdk.Screen.get_default()
@@ -72,12 +65,9 @@ class VPNManagerWindow:
main_vbox.set_margin_bottom(12) main_vbox.set_margin_bottom(12)
self.window.add(main_vbox) self.window.add(main_vbox)
# Current location display # Current location display - enhanced info box
self.current_location_label = Gtk.Label() self.location_info_box = self._create_location_info_box()
self.current_location_label.set_markup("<i>Current location: Not set</i>") main_vbox.pack_start(self.location_info_box, False, False, 0)
self.current_location_label.set_halign(Gtk.Align.CENTER)
self.current_location_label.set_margin_bottom(8)
main_vbox.pack_start(self.current_location_label, False, False, 0)
# Search bar with SearchEntry # Search bar with SearchEntry
self.search_entry = Gtk.SearchEntry() self.search_entry = Gtk.SearchEntry()
@@ -103,50 +93,53 @@ class VPNManagerWindow:
self.inactive_view = InactiveView(callbacks) self.inactive_view = InactiveView(callbacks)
self.view_stack.add_named(self.inactive_view.widget, "inactive") self.view_stack.add_named(self.inactive_view.widget, "inactive")
# Create log section at bottom (collapsible)
self._create_log_section(main_vbox)
# Initialize VPN manager (temporarily disabled due to syntax errors)
# TODO: Fix VPN manager syntax and re-enable
self.vpn_manager = None
self.log_view.log_info(
"VPN manager temporarily disabled for debugging")
self.log_view.log_info("Using mock mode for VPN operations")
# Render initial data # Render initial data
self.render_customers() self.render_customers()
def setup_system_tray(self): # Update VPN status from actual connections
# Create a simple icon for the system tray self.update_vpn_status()
def create_icon():
# Create a simple network icon
width = height = 64
image = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
# Draw a simple network/VPN icon def _setup_logging(self):
# Outer circle """Set up logging to route VPN manager logs to LogView."""
draw.ellipse([8, 8, 56, 56], outline=(50, 150, 50), width=4) # Create a custom handler that forwards to our LogView
# Inner dot class LogViewHandler(logging.Handler):
draw.ellipse([26, 26, 38, 38], fill=(50, 150, 50)) def __init__(self, log_view):
# Connection lines super().__init__()
draw.line([32, 16, 32, 24], fill=(50, 150, 50), width=3) self.log_view = log_view
draw.line([32, 40, 32, 48], fill=(50, 150, 50), width=3)
draw.line([16, 32, 24, 32], fill=(50, 150, 50), width=3)
draw.line([40, 32, 48, 32], fill=(50, 150, 50), width=3)
return image def emit(self, record):
try:
msg = self.format(record)
if record.levelno >= logging.ERROR:
self.log_view.log_error(msg)
elif record.levelno >= logging.WARNING:
self.log_view.log_warning(msg)
elif record.levelno >= logging.INFO:
self.log_view.log_info(msg)
else: # DEBUG
self.log_view.log_debug(msg)
except Exception:
self.handleError(record)
# Simple approach: Create tray icon with direct action and minimal menu # Set up handler for VPN manager logs
self.tray_icon = pystray.Icon( handler = LogViewHandler(self.log_view)
"VPN Manager", handler.setFormatter(logging.Formatter('%(message)s'))
create_icon(),
"VPN Manager - Double-click to open"
)
# Set direct click action # Add handler to VPN manager logger
self.tray_icon.default_action = self.show_window_from_tray vpn_logger = logging.getLogger('services.vpn_manager')
vpn_logger.addHandler(handler)
# Also provide a right-click menu vpn_logger.setLevel(logging.DEBUG)
menu = pystray.Menu( vpn_logger.propagate = False # Don't send to root logger
pystray.MenuItem("Open VPN Manager",
self.show_window_from_tray, default=True),
pystray.MenuItem("Quit", self.quit_app)
)
self.tray_icon.menu = menu
# Start tray icon in separate thread
threading.Thread(target=self.tray_icon.run, daemon=True).start()
def get_callbacks(self): def get_callbacks(self):
"""Return callback functions for widget interactions""" """Return callback functions for widget interactions"""
@@ -204,6 +197,8 @@ class VPNManagerWindow:
target_location = customer.get_location_by_name(location.name) target_location = customer.get_location_by_name(location.name)
if target_location: if target_location:
target_location.active = True target_location.active = True
self.log_view.log_info(
f"Activated location: {customer.name} - {target_location.name}")
print( print(
f"Mock: Setting {customer.name} - {target_location.name} as active") f"Mock: Setting {customer.name} - {target_location.name} as active")
break break
@@ -219,6 +214,8 @@ class VPNManagerWindow:
if target_location: if target_location:
target_location.active = False target_location.active = False
target_location.connected = False # Disconnect when deactivating target_location.connected = False # Disconnect when deactivating
self.log_view.log_info(
f"Deactivated location: {customer.name} - {target_location.name}")
print( print(
f"Mock: Deactivating {customer.name} - {target_location.name}") f"Mock: Deactivating {customer.name} - {target_location.name}")
break break
@@ -230,20 +227,305 @@ class VPNManagerWindow:
if customer.name == customer_name: if customer.name == customer_name:
target_location = customer.get_location_by_name(location.name) target_location = customer.get_location_by_name(location.name)
if target_location: if target_location:
self.current_location = (customer.name, target_location.name) self.current_location = (
print(f"Current location set to: {customer.name} - {target_location.name}") customer.name, target_location.name)
self.log_view.log_info(
f"Current location set to: {customer.name} - {target_location.name}")
print(
f"Current location set to: {customer.name} - {target_location.name}")
self.update_current_location_display() self.update_current_location_display()
break break
def _create_location_info_box(self):
"""Create the enhanced current location info box."""
frame = Gtk.Frame()
frame.get_style_context().add_class("location-info")
frame.set_shadow_type(Gtk.ShadowType.NONE)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
frame.add(vbox)
# Title row with infrastructure toggle
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
vbox.pack_start(title_box, False, False, 0)
title_label = Gtk.Label()
title_label.set_markup("<b>📍 Current Location</b>")
title_label.set_halign(Gtk.Align.START)
title_box.pack_start(title_label, False, False, 0)
# Infrastructure toggle button (only shown when location is set)
self.infrastructure_toggle = Gtk.Button()
self.infrastructure_toggle.set_relief(Gtk.ReliefStyle.NONE)
self.infrastructure_toggle.set_can_focus(False)
self.infrastructure_toggle.set_label("")
self.infrastructure_toggle.set_tooltip_text("Show/hide infrastructure")
self.infrastructure_toggle.connect(
"clicked", self._on_infrastructure_toggle)
self.infrastructure_toggle.set_visible(False)
title_box.pack_end(self.infrastructure_toggle, False, False, 0)
# Location details label
self.location_details_label = Gtk.Label()
self.location_details_label.set_markup("<i>Not set</i>")
self.location_details_label.set_halign(Gtk.Align.START)
vbox.pack_start(self.location_details_label, False, False, 0)
# Additional info row (hosts, services, etc.)
self.location_extra_info = Gtk.Label()
self.location_extra_info.set_halign(Gtk.Align.START)
self.location_extra_info.set_visible(False)
vbox.pack_start(self.location_extra_info, False, False, 0)
# Infrastructure section (collapsible)
self.infrastructure_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.infrastructure_box.set_margin_top(8)
self.infrastructure_box.set_visible(False)
vbox.pack_start(self.infrastructure_box, False, False, 0)
# Track infrastructure expanded state
self.infrastructure_expanded = False
return frame
def _create_log_section(self, main_vbox):
"""Create the collapsible log section at the bottom."""
# Log section container
log_container = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=0)
log_container.get_style_context().add_class("log-section")
main_vbox.pack_end(log_container, False, False, 0)
# Log header with toggle button
log_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
log_header.set_margin_start(12)
log_header.set_margin_end(12)
log_header.set_margin_top(8)
log_header.set_margin_bottom(8)
log_container.pack_start(log_header, False, False, 0)
# Toggle button for log visibility
self.log_toggle = Gtk.Button()
self.log_toggle.set_relief(Gtk.ReliefStyle.NONE)
self.log_toggle.set_can_focus(False)
self.log_toggle.set_label("")
self.log_toggle.set_tooltip_text("Show/hide command log")
self.log_toggle.connect("clicked", self._on_log_toggle)
log_header.pack_start(self.log_toggle, False, False, 0)
# Log section label
log_section_label = Gtk.Label()
log_section_label.set_markup("<b>Command Log</b>")
log_section_label.set_halign(Gtk.Align.START)
log_header.pack_start(log_section_label, False, False, 0)
# Create the log view
self.log_view = LogView()
log_container.pack_start(self.log_view.widget, False, False, 0)
# Start with log collapsed
self.log_expanded = False
self.log_view.set_visible(False)
# Log some initial messages
self.log_view.log_info("VPN Manager started")
self.log_view.log_info(f"Loaded {len(self.customers)} customers")
def _on_log_toggle(self, button):
"""Toggle log section visibility."""
self.log_expanded = not self.log_expanded
if self.log_expanded:
self.log_toggle.set_label("")
self.log_view.set_visible(True)
else:
self.log_toggle.set_label("")
self.log_view.set_visible(False)
def update_current_location_display(self): def update_current_location_display(self):
"""Update the current location display label.""" """Update the current location display with detailed information."""
if self.current_location: if self.current_location:
customer_name, location_name = self.current_location customer_name, location_name = self.current_location
self.current_location_label.set_markup(
f"<i>📍 Current location: <b>{customer_name} - {location_name}</b></i>" # Find the actual location object
location = None
for customer in self.customers:
if customer.name == customer_name:
location = customer.get_location_by_name(location_name)
if location:
break
if location:
# Main location info
self.location_details_label.set_markup(
f"<b>{customer_name}</b> - {location_name}"
) )
# Extra info about the location
host_count = len(location.hosts)
total_hosts = len(location.get_all_hosts_flat())
vpn_type = location.vpn_type.value
extra_text = f"<small>{vpn_type} VPN"
if location.external_addresses:
if len(location.external_addresses) == 1:
extra_text += f" • 🌐 {location.external_addresses[0]}"
else: else:
self.current_location_label.set_markup("<i>Current location: Not set</i>") extra_text += f" • 🌐 {len(location.external_addresses)} endpoints"
if location.networks:
extra_text += f" • 📡 {len(location.networks)} network{'s' if len(location.networks) > 1 else ''}"
extra_text += f"{host_count} hosts"
if total_hosts > host_count:
extra_text += f" ({total_hosts} total with VMs)"
extra_text += "</small>"
self.location_extra_info.set_markup(extra_text)
self.location_extra_info.set_visible(True)
# Show infrastructure toggle and rebuild infrastructure
self.infrastructure_toggle.set_visible(True)
self._rebuild_infrastructure_display(location)
else:
self.location_details_label.set_markup(
f"<b>{customer_name}</b> - {location_name}"
)
self.location_extra_info.set_visible(False)
self.infrastructure_toggle.set_visible(False)
else:
self.location_details_label.set_markup("<i>Not set</i>")
self.location_extra_info.set_visible(False)
self.infrastructure_toggle.set_visible(False)
self.infrastructure_box.set_visible(False)
def _rebuild_infrastructure_display(self, location):
"""Rebuild the infrastructure display for the current location."""
# Clear existing infrastructure widgets
for child in self.infrastructure_box.get_children():
child.destroy()
# Add network information if available
if location.networks or location.external_addresses:
network_label = Gtk.Label()
network_label.set_markup("<b>Network Configuration</b>")
network_label.set_halign(Gtk.Align.START)
network_label.set_margin_bottom(4)
self.infrastructure_box.pack_start(network_label, False, False, 0)
# External addresses
if location.external_addresses:
for i, address in enumerate(location.external_addresses):
ext_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
ext_box.set_margin_start(12)
self.infrastructure_box.pack_start(
ext_box, False, False, 0)
label_text = "🌐 <b>External:</b>" if i == 0 else "🌐 <b>Backup:</b>"
ext_label = Gtk.Label()
ext_label.set_markup(
f"<small>{label_text} {address}</small>")
ext_label.set_halign(Gtk.Align.START)
ext_box.pack_start(ext_label, False, False, 0)
# Internal networks
if location.networks:
for network in location.networks:
net_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
net_box.set_margin_start(12)
self.infrastructure_box.pack_start(
net_box, False, False, 0)
net_label = Gtk.Label()
net_label.set_markup(
f"<small>📡 <b>Network:</b> {network}</small>")
net_label.set_halign(Gtk.Align.START)
net_box.pack_start(net_label, False, False, 0)
# Add spacing before infrastructure
if location.hosts:
spacer = Gtk.Box()
spacer.set_size_request(-1, 8)
self.infrastructure_box.pack_start(spacer, False, False, 0)
if not location.hosts:
return
# Add infrastructure label
infra_label = Gtk.Label()
infra_label.set_markup("<b>Infrastructure</b>")
infra_label.set_halign(Gtk.Align.START)
infra_label.set_margin_bottom(4)
self.infrastructure_box.pack_start(infra_label, False, False, 0)
# Add hosts
for host in location.hosts:
host_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
host_box.set_margin_start(12)
self.infrastructure_box.pack_start(host_box, False, False, 0)
# Host type icon
host_type_icons = {
'Linux': '🐧',
'Windows': '🪟',
'Windows Server': '🏢',
'Proxmox': '📦',
'ESXi': '⚙️',
'Router': '📡',
'Switch': '🔀'
}
icon = host_type_icons.get(host.host_type.value, '💻')
# Host info
host_label = Gtk.Label()
service_count = len(host.services)
vm_count = len(host.sub_hosts)
host_text = f"{icon} <b>{host.name}</b> ({host.ip_address})"
if service_count > 0:
host_text += f"{service_count} services"
if vm_count > 0:
host_text += f"{vm_count} VMs"
host_label.set_markup(f"<small>{host_text}</small>")
host_label.set_halign(Gtk.Align.START)
host_box.pack_start(host_label, False, False, 0)
# Add sub-hosts (VMs) if any
if host.sub_hosts:
for vm in host.sub_hosts:
vm_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
vm_box.set_margin_start(24)
self.infrastructure_box.pack_start(vm_box, False, False, 0)
vm_icon = host_type_icons.get(vm.host_type.value, '💻')
vm_service_count = len(vm.services)
vm_text = f"{vm_icon} <i>{vm.name}</i> ({vm.ip_address})"
if vm_service_count > 0:
vm_text += f"{vm_service_count} services"
vm_label = Gtk.Label()
vm_label.set_markup(f"<small>{vm_text}</small>")
vm_label.set_halign(Gtk.Align.START)
vm_box.pack_start(vm_label, False, False, 0)
# Show all widgets (but container might be hidden)
self.infrastructure_box.show_all()
def _on_infrastructure_toggle(self, button):
"""Toggle infrastructure section visibility."""
self.infrastructure_expanded = not self.infrastructure_expanded
if self.infrastructure_expanded:
self.infrastructure_toggle.set_label("")
self.infrastructure_box.set_visible(True)
else:
self.infrastructure_toggle.set_label("")
self.infrastructure_box.set_visible(False)
def filter_customers(self, entry): def filter_customers(self, entry):
search_term = entry.get_text().strip() search_term = entry.get_text().strip()
@@ -279,8 +561,12 @@ class VPNManagerWindow:
# Check hosts and their services in this location # Check hosts and their services in this location
def search_hosts(hosts): def search_hosts(hosts):
for host in hosts: for host in hosts:
# Check IP addresses (search in any of the host's IPs)
ip_match = any(search_term_lower in host_ip.ip_address.lower(
) for host_ip in host.ip_addresses)
if (search_term_lower in host.name.lower() or if (search_term_lower in host.name.lower() or
search_term_lower in host.ip_address.lower() or ip_match or
search_term_lower in host.host_type.value.lower() or search_term_lower in host.host_type.value.lower() or
search_term_lower in host.description.lower()): search_term_lower in host.description.lower()):
return True return True
@@ -307,11 +593,34 @@ class VPNManagerWindow:
self.render_customers() self.render_customers()
def toggle_connection(self, location): def toggle_connection(self, location):
location.connected = not location.connected # Use actual VPN manager
status = "connected to" if location.connected else "disconnected from" if location.connected:
print(f"Mock: {status} {location.name} via {location.vpn_type.value}") # Disconnect
self.log_view.log_info(f"Disconnecting from {location.name}...")
success = self.vpn_manager.disconnect_vpn(location)
if success:
location.connected = False
self.log_view.log_success(f"Disconnected from {location.name}")
else:
self.log_view.log_error(
f"Failed to disconnect from {location.name}")
else:
# Connect
self.log_view.log_info(
f"Connecting to {location.name} via {location.vpn_type.value}...")
success = self.vpn_manager.connect_vpn(location)
if success:
location.connected = True
self.log_view.log_success(f"Connected to {location.name}")
else:
self.log_view.log_error(
f"Failed to connect to {location.name}")
self.render_customers() self.render_customers()
# Update VPN status after connection change
self.update_vpn_status()
def open_service(self, service): def open_service(self, service):
# Get the host IP from context - this would need to be passed properly in a real implementation # Get the host IP from context - this would need to be passed properly in a real implementation
print( print(
@@ -343,6 +652,23 @@ class VPNManagerWindow:
self.quit_app() self.quit_app()
return False return False
def update_vpn_status(self):
"""Update location connection status from actual VPN manager."""
if not self.vpn_manager:
return
# Only update status for active locations to avoid unnecessary nmcli calls
for customer in self.customers:
for location in customer.locations:
if location.active: # Only check active locations
try:
status = self.vpn_manager.get_connection_status(
location)
location.connected = (status == VPNStatus.CONNECTED)
except VPNConnectionError:
# If we can't get status, assume disconnected
location.connected = False
def quit_app(self, _widget=None): def quit_app(self, _widget=None):
# Stop the tray icon # Stop the tray icon
if hasattr(self, 'tray_icon'): if hasattr(self, 'tray_icon'):

187
models.py
View File

@@ -40,12 +40,45 @@ class VPNType(Enum):
IPSEC = "IPSec" IPSEC = "IPSec"
@dataclass
class NetworkSegment:
"""Represents a network segment with metadata."""
name: str # "LAN", "DMZ", "Management"
cidr: str # "192.168.1.0/24"
vlan_id: Optional[int] = None # VLAN 100
zone: str = "general" # "production", "dmz", "management", "guest"
gateway: Optional[str] = None # "192.168.1.1"
description: str = "" # "Main office network"
@dataclass
class PortForwarding:
"""Represents a port forwarding rule for external access."""
external_port: int # Port on external address (e.g., 8080)
# Target internal IP (e.g., "192.168.1.10")
internal_ip: str
internal_port: int # Target internal port (e.g., 80)
protocol: str = "tcp" # "tcp", "udp", or "both"
description: str = "" # "Web server access"
enabled: bool = True # Whether the forwarding is active
@dataclass
class HostIP:
"""IP address with network segment context."""
ip_address: str
network_segment: str # References NetworkSegment.name
is_primary: bool = False # Primary interface for this host
@dataclass @dataclass
class Host: class Host:
"""Represents a physical or virtual host at a location.""" """Represents a physical or virtual host at a location."""
name: str name: str
ip_address: str ip_addresses: List[HostIP] = field(default_factory=list)
host_type: HostType host_type: HostType = HostType.LINUX
# Icon name without extension (e.g., 'ubuntu', 'windows')
icon: Optional[str] = None
description: str = "" description: str = ""
services: List[Service] = field(default_factory=list) services: List[Service] = field(default_factory=list)
sub_hosts: List['Host'] = field( sub_hosts: List['Host'] = field(
@@ -62,6 +95,38 @@ class Host:
"""Check if this host has sub-hosts (VMs).""" """Check if this host has sub-hosts (VMs)."""
return len(self.sub_hosts) > 0 return len(self.sub_hosts) > 0
def get_primary_ip(self) -> str:
"""Get the primary IP address, or first IP if no primary set."""
if not self.ip_addresses:
return ""
# Look for explicitly marked primary
for host_ip in self.ip_addresses:
if host_ip.is_primary:
return host_ip.ip_address
# Fall back to first IP
return self.ip_addresses[0].ip_address
def get_ip_display(self) -> str:
"""Get a display string for IP addresses."""
if not self.ip_addresses:
return "No IP"
elif len(self.ip_addresses) == 1:
return self.ip_addresses[0].ip_address
else:
primary_ip = self.get_primary_ip()
return f"{primary_ip} (+{len(self.ip_addresses)-1} more)"
def get_all_ips(self) -> List[str]:
"""Get all IP addresses as a simple list."""
return [host_ip.ip_address for host_ip in self.ip_addresses]
def get_ips_in_segment(self, segment_name: str) -> List[str]:
"""Get all IP addresses in a specific network segment."""
return [host_ip.ip_address for host_ip in self.ip_addresses
if host_ip.network_segment == segment_name]
@dataclass @dataclass
class Location: class Location:
@@ -73,8 +138,21 @@ class Location:
vpn_config: str = "" # Path to VPN config or connection details vpn_config: str = "" # Path to VPN config or connection details
hosts: List[Host] = field(default_factory=list) hosts: List[Host] = field(default_factory=list)
# Enhanced network configuration
network_segments: List[NetworkSegment] = field(
default_factory=list) # Network segments with rich metadata
external_addresses: List[str] = field(
default_factory=list) # External VPN endpoints
port_forwardings: List[PortForwarding] = field(
default_factory=list) # Port forwarding rules
# Legacy field for backward compatibility (will be deprecated)
# Simple network list (legacy)
networks: List[str] = field(default_factory=list)
# VPN connection management fields # VPN connection management fields
nmcli_connection_name: Optional[str] = None # NetworkManager connection name # NetworkManager connection name
nmcli_connection_name: Optional[str] = None
auto_import: bool = True # Auto-import .ovpn file if not in NetworkManager auto_import: bool = True # Auto-import .ovpn file if not in NetworkManager
# Credential storage - can be: # Credential storage - can be:
@@ -112,6 +190,109 @@ class Location:
"""Get all hosts that have sub-hosts (hypervisors).""" """Get all hosts that have sub-hosts (hypervisors)."""
return [host for host in self.get_all_hosts_flat() if host.is_hypervisor()] return [host for host in self.get_all_hosts_flat() if host.is_hypervisor()]
def get_segment_by_name(self, segment_name: str) -> Optional[NetworkSegment]:
"""Get a network segment by its name."""
return next((seg for seg in self.network_segments if seg.name == segment_name), None)
def get_hosts_in_segment(self, segment_name: str) -> List[Host]:
"""Get all hosts that have IPs in the specified network segment."""
hosts = []
for host in self.get_all_hosts_flat():
if any(host_ip.network_segment == segment_name for host_ip in host.ip_addresses):
hosts.append(host)
return hosts
def get_segments_by_zone(self, zone: str) -> List[NetworkSegment]:
"""Get all network segments in a specific zone."""
return [seg for seg in self.network_segments if seg.zone == zone]
def get_port_forwardings_for_host(self, host_ip: str) -> List[PortForwarding]:
"""Get all port forwardings targeting a specific host IP."""
return [pf for pf in self.port_forwardings if pf.internal_ip == host_ip and pf.enabled]
def get_externally_accessible_services(self) -> List[tuple]:
"""Get all services accessible from external addresses via port forwarding.
Returns list of tuples: (external_address, external_port, host, service, port_forwarding)
"""
accessible_services = []
for external_addr in self.external_addresses:
for port_forward in self.port_forwardings:
if not port_forward.enabled:
continue
# Find the host that owns the target IP
target_host = None
target_service = None
for host in self.get_all_hosts_flat():
host_ips = [hip.ip_address for hip in host.ip_addresses]
if port_forward.internal_ip in host_ips:
target_host = host
# Find matching service on this host
for service in host.services:
if service.port == port_forward.internal_port:
target_service = service
break
break
if target_host:
accessible_services.append((
external_addr,
port_forward.external_port,
target_host,
target_service, # May be None if no matching service defined
port_forward
))
return accessible_services
def is_service_externally_accessible(self, host_ip: str, service_port: int) -> bool:
"""Check if a specific service is accessible from external addresses."""
for pf in self.port_forwardings:
if (pf.enabled and
pf.internal_ip == host_ip and
pf.internal_port == service_port):
return True
return False
def is_service_reachable(self, host: 'Host', service: Service) -> bool:
"""Check if a service is reachable (either via VPN connection or port forwarding).
Returns True if:
- VPN is connected (all internal services become reachable)
- Service has a port forwarding rule enabled
"""
# If VPN is connected, all services are reachable
if self.connected:
return True
# Check if service is externally accessible via port forwarding
for host_ip in host.ip_addresses:
if self.is_service_externally_accessible(host_ip.ip_address, service.port):
return True
return False
def get_external_url_for_service(self, host: 'Host', service: Service) -> Optional[str]:
"""Get the external URL for a service if it has port forwarding.
Returns the external URL (e.g., "https://vpn.example.com:8006") or None.
"""
for host_ip in host.ip_addresses:
for pf in self.port_forwardings:
if (pf.enabled and
pf.internal_ip == host_ip.ip_address and
pf.internal_port == service.port):
# Use first external address if available
if self.external_addresses:
protocol = "https" if service.port in [
443, 8006, 8080] else "http"
return f"{protocol}://{self.external_addresses[0]}:{pf.external_port}"
return None
@dataclass @dataclass
class CustomerService: class CustomerService:

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
"""VPN connection management using NetworkManager (nmcli).""" """Enhanced VPN management with VPNTray naming and route control."""
import subprocess import subprocess
import tempfile
import os
import re import re
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, Dict, List from typing import Optional, List
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from models import Location
class VPNStatus(Enum): class VPNStatus(Enum):
@@ -26,286 +26,482 @@ class VPNConnectionError(Exception):
@dataclass @dataclass
class VPNConnection: class VPNConnectionInfo:
"""Represents a NetworkManager VPN connection.""" """Information about a VPN connection."""
name: str name: str
uuid: str uuid: str
type: str vpntray_name: str # Our custom name with vpntray_ prefix
status: VPNStatus
device: Optional[str] = None device: Optional[str] = None
state: VPNStatus = VPNStatus.UNKNOWN routes: List[str] = None # List of routes added
vpn_type: Optional[str] = None # OpenVPN, WireGuard, etc.
class VPNManager: 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): def __init__(self):
"""Initialize VPN manager and check for nmcli availability.""" """Initialize VPN manager."""
self.logger = logging.getLogger(__name__)
self._check_nmcli_available() self._check_nmcli_available()
self._ensure_vpn_config_dir()
def _check_nmcli_available(self) -> None: def _check_nmcli_available(self) -> None:
"""Check if nmcli is available on the system.""" """Check if nmcli is available."""
try: try:
subprocess.run(['nmcli', '--version'], subprocess.run(['nmcli', '--version'],
capture_output=True, check=True) capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
raise VPNConnectionError( 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: try:
result = subprocess.run( result = subprocess.run(
['nmcli'] + args, command,
capture_output=True, capture_output=True,
text=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 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: except subprocess.CalledProcessError as e:
raise VPNConnectionError(f"nmcli command failed: {e.stderr}") self.logger.debug(f"Failed command: {command_str}")
if e.stdout and e.stdout.strip():
def import_ovpn(self, ovpn_path: str, connection_name: Optional[str] = None) -> str: self.logger.debug(f"Output: {e.stdout.strip()}")
"""Import an OpenVPN configuration file. if e.stderr and e.stderr.strip():
self.logger.error(f"Error: {e.stderr.strip()}")
Args: error_details = e.stderr or str(e)
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( raise VPNConnectionError(
f"OpenVPN config file not found: {ovpn_path}") f"nmcli command failed (exit code {e.returncode}): {error_details}")
# Import the configuration def _get_vpntray_connection_name(self, config_filename: str) -> str:
result = self._run_nmcli([ """Generate VPNTray-specific connection name."""
'connection', 'import', 'type', 'openvpn', 'file', str(ovpn_file) # 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 def get_vpn_config_path(self, filename: str) -> Path:
# nmcli typically outputs: "Connection 'name' (uuid) successfully added." """Get full path to VPN config file."""
match = re.search(r"Connection '([^']+)'", result.stdout) return self.VPN_CONFIG_DIR / filename
if not match:
raise VPNConnectionError(
"Failed to parse imported connection name")
imported_name = match.group(1) def list_vpntray_connections(self) -> List[VPNConnectionInfo]:
"""List all VPNTray-managed connections."""
# Rename if custom name provided connections = []
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: try:
self._run_nmcli([ result = self._run_nmcli(['connection', 'show'])
'connection', 'up', connection_name, for line in result.stdout.strip().split('\\n'):
'passwd-file', secrets_file if self.VPNTRAY_PREFIX in line:
])
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() parts = line.split()
if len(parts) >= 4: if len(parts) >= 4:
name = parts[0] name = parts[0]
uuid = parts[1] uuid = parts[1]
conn_type = parts[2]
device = parts[3] if parts[3] != '--' else None device = parts[3] if parts[3] != '--' else None
# Get current status # Get detailed status
status = self.get_status(name) status = self._get_connection_status(name)
connections.append(VPNConnection( connections.append(VPNConnectionInfo(
name=name, name=name,
uuid=uuid, uuid=uuid,
type=conn_type, vpntray_name=name,
device=device, status=status,
state=status device=device
)) ))
except VPNConnectionError:
pass # No connections or nmcli error
return connections return connections
def delete_connection(self, connection_name: str) -> None: def _get_connection_status(self, connection_name: str) -> VPNStatus:
"""Delete a NetworkManager connection. """Get the status of a specific connection."""
try:
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]) result = self._run_nmcli(['connection', 'show', connection_name])
details = {} # Parse connection state from output
for line in result.stdout.strip().split('\n'): for line in result.stdout.split('\\n'):
if ':' in line: if 'GENERAL.STATE:' in line:
key, value = line.split(':', 1) state = line.split(':')[1].strip()
details[key.strip()] = value.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
return details return VPNStatus.UNKNOWN
def get_active_vpn_interface(self, connection_name: str) -> Optional[str]: def import_vpn_config(self, location: Location) -> str:
"""Get the network interface used by an active VPN connection. """Import VPN configuration for a location with VPNTray naming."""
config_path = self.get_vpn_config_path(location.vpn_config)
Args: if not config_path.exists():
connection_name: Name of the VPN connection raise VPNConnectionError(f"VPN config not found: {config_path}")
Returns: self.logger.info(
Interface name (e.g., 'tun0') or None if not connected f"Config file exists: {config_path} ({config_path.stat().st_size} bytes)")
"""
if self.get_status(connection_name) != VPNStatus.CONNECTED: 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,
'ipv4.never-default', 'true'
])
# Add routes for each network segment
routes = []
for segment in location.network_segments:
# Add route for the network segment
routes.append(segment.cidr)
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}")
except VPNConnectionError as e:
self.logger.error(f"Failed to configure routes: {e}")
# Don't fail the import, just log the error
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 return None
details = self.get_connection_details(connection_name) def connect_vpn(self, location: Location) -> bool:
return details.get('GENERAL.DEVICES') """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]: # Check if config file exists
"""Get the IP address assigned to the VPN connection. if not config_path.exists():
error_msg = f"VPN config file not found: {config_path}"
self.logger.error(error_msg)
return False
Args: # Import if not already imported
connection_name: Name of the VPN connection 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: # Connect with simple command - credentials already set during import
IP address or None if not connected self.logger.info("Attempting connection...")
"""
interface = self.get_active_vpn_interface(connection_name)
if not interface:
return None
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'): return True
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 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

56
style.css Normal file
View File

@@ -0,0 +1,56 @@
.card {
background: @theme_base_color;
border-radius: 8px;
border: 1px solid @borders;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 16px;
margin: 6px;
}
.location-info {
background: linear-gradient(to bottom, alpha(@theme_selected_bg_color, 0.1), alpha(@theme_selected_bg_color, 0.05));
border-radius: 8px;
border: 1px solid alpha(@theme_selected_bg_color, 0.3);
padding: 12px;
margin-bottom: 12px;
}
.log-section {
background: @theme_base_color;
border-top: 1px solid @borders;
border-radius: 8px 8px 0 0;
}
/* Material Icons font */
.material-icons {
font-family: "Material Icons";
font-weight: normal;
font-style: normal;
font-size: 18px;
}
/* Service button color coding with Material Icons */
.service-icon-accessible {
font-family: "Material Icons";
color: #4caf50;
/* Green for accessible */
font-size: 18px;
}
.service-icon-inaccessible {
font-family: "Material Icons";
color: #f44336;
/* Red for not accessible */
font-size: 18px;
}
.service-icon-accessible:hover {
color: #2e7d32;
/* Darker green on hover */
}
.service-icon-inaccessible:disabled {
color: #ef9a9a;
/* Lighter red when disabled */
opacity: 0.6;
}

5
utils/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Utility modules for VPN Manager."""
from .icon_loader import IconLoader
__all__ = ['IconLoader']

142
utils/icon_loader.py Normal file
View File

@@ -0,0 +1,142 @@
"""Icon loader utility for host icons with fallback support."""
import os
from pathlib import Path
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GdkPixbuf
from models import HostType
class IconLoader:
"""Manages loading of host icons with fallback to Material Icons."""
# Default icon size
ICON_SIZE = 20
# Project root directory
PROJECT_ROOT = Path(__file__).parent.parent
ICONS_DIR = PROJECT_ROOT / "assets" / "icons"
# Material Icons fallback mapping for host types
HOST_TYPE_ICONS = {
HostType.LINUX: "computer",
HostType.WINDOWS: "desktop_windows",
HostType.WINDOWS_SERVER: "dns",
HostType.PROXMOX: "developer_board",
HostType.ESXI: "developer_board",
HostType.ROUTER: "router",
HostType.SWITCH: "device_hub"
}
@classmethod
def get_host_icon_widget(cls, host, size=None) -> Gtk.Widget:
"""Get an icon widget for a host, either custom SVG or Material Icon fallback.
Args:
host: Host object with optional icon field
size: Icon size in pixels (default: ICON_SIZE)
Returns:
Gtk.Image if custom icon exists, Gtk.Label with Material Icon otherwise
"""
if size is None:
size = cls.ICON_SIZE
# Try custom icon first
if host.icon:
icon_widget = cls._load_custom_icon(host.icon, size)
if icon_widget:
return icon_widget
# Fallback to Material Icons based on host type
return cls._create_material_icon(host.host_type, size)
@classmethod
def _load_custom_icon(cls, icon_name: str, size: int) -> Gtk.Image:
"""Load a custom SVG icon from assets/icons directory.
Args:
icon_name: Name of the icon file without extension (e.g., 'ubuntu')
size: Icon size in pixels
Returns:
Gtk.Image if icon exists, None otherwise
"""
# Try SVG first, then PNG
for extension in ['.svg', '.png']:
icon_path = cls.ICONS_DIR / f"{icon_name}{extension}"
if icon_path.exists():
try:
# Load and scale the icon
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
str(icon_path), size, size
)
image = Gtk.Image.new_from_pixbuf(pixbuf)
return image
except Exception as e:
print(f"Failed to load icon {icon_path}: {e}")
return None
@classmethod
def _create_material_icon(cls, host_type: HostType, size: int) -> Gtk.Label:
"""Create a Material Icon label for a host type.
Args:
host_type: HostType enum value
size: Icon size in pixels
Returns:
Gtk.Label with Material Icon
"""
icon_name = cls.HOST_TYPE_ICONS.get(host_type, "computer")
label = Gtk.Label()
label.set_text(icon_name)
label.get_style_context().add_class("material-icons")
# Apply custom CSS for size
css = f"""
#{label.get_name()} {{
font-size: {size}px;
}}
"""
return label
@classmethod
def get_service_icon(cls, service_type: str, is_accessible: bool) -> Gtk.Label:
"""Get a Material Icon for a service with color coding.
Args:
service_type: Service type string (e.g., 'SSH', 'Web GUI')
is_accessible: Whether the service is currently accessible
Returns:
Gtk.Label with colored Material Icon
"""
# Service type to Material Icons mapping
service_icons = {
'SSH': 'terminal',
'Web GUI': 'language',
'RDP': 'desktop_windows',
'VNC': 'monitor',
'SMB': 'folder_shared',
'Database': 'storage',
'FTP': 'cloud_upload'
}
icon_name = service_icons.get(service_type, 'settings')
label = Gtk.Label()
label.set_text(icon_name)
# Apply color based on accessibility
if is_accessible:
label.get_style_context().add_class("service-icon-accessible")
else:
label.get_style_context().add_class("service-icon-inaccessible")
return label

View File

@@ -1,4 +1,5 @@
from .active_view import ActiveView from .active_view import ActiveView
from .inactive_view import InactiveView from .inactive_view import InactiveView
from .log_view import LogView, LogLevel
__all__ = ['ActiveView', 'InactiveView'] __all__ = ['ActiveView', 'InactiveView', 'LogView', 'LogLevel']

View File

@@ -1,7 +1,7 @@
from widgets import ActiveCustomerCard
from gi.repository import Gtk
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from widgets import ActiveCustomerCard
class ActiveView: class ActiveView:
@@ -23,7 +23,8 @@ class ActiveView:
vbox.pack_start(scrolled, True, True, 0) vbox.pack_start(scrolled, True, True, 0)
# Content box # Content box
self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) self.content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=12)
scrolled.add(self.content_box) scrolled.add(self.content_box)
return vbox return vbox
@@ -42,11 +43,13 @@ class ActiveView:
# Add customer cards # Add customer cards
for customer in customers: for customer in customers:
customer_card = ActiveCustomerCard(customer, self.callbacks) customer_card = ActiveCustomerCard(customer, self.callbacks)
self.content_box.pack_start(customer_card.widget, False, False, 0) self.content_box.pack_start(
customer_card.widget, False, False, 0)
else: else:
# Show empty state message # Show empty state message
no_active_label = Gtk.Label() no_active_label = Gtk.Label()
no_active_label.set_markup("<span alpha='50%'>No active locations</span>") no_active_label.set_markup(
"<span alpha='50%'>No active locations</span>")
no_active_label.set_margin_top(20) no_active_label.set_margin_top(20)
self.content_box.pack_start(no_active_label, False, False, 0) self.content_box.pack_start(no_active_label, False, False, 0)

View File

@@ -1,7 +1,7 @@
from widgets import InactiveCustomerCard
from gi.repository import Gtk
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from widgets import InactiveCustomerCard
class InactiveView: class InactiveView:
@@ -24,7 +24,8 @@ class InactiveView:
vbox.pack_start(scrolled, True, True, 0) vbox.pack_start(scrolled, True, True, 0)
# Content box # Content box
self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) self.content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=12)
scrolled.add(self.content_box) scrolled.add(self.content_box)
return vbox return vbox
@@ -46,7 +47,8 @@ class InactiveView:
# Add customer cards # Add customer cards
for customer in customers: for customer in customers:
customer_card = InactiveCustomerCard(customer, self.callbacks) customer_card = InactiveCustomerCard(customer, self.callbacks)
self.content_box.pack_start(customer_card.widget, False, False, 0) self.content_box.pack_start(
customer_card.widget, False, False, 0)
else: else:
# Show no results message # Show no results message
if search_term: if search_term:

275
views/log_view.py Normal file
View File

@@ -0,0 +1,275 @@
"""Log view for displaying command output and system logs."""
from enum import Enum
from typing import Optional
import time
from gi.repository import Gtk, GLib, Pango
import gi
gi.require_version('Gtk', '3.0')
class LogLevel(Enum):
"""Log levels for different types of messages."""
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
COMMAND = "COMMAND"
class LogView:
"""View for displaying logs and command output."""
def __init__(self):
self.widget = self._create_widget()
self.max_lines = 1000 # Maximum number of log lines to keep
self.auto_scroll = True
def _create_widget(self):
"""Create the main log view widget."""
# Main container
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
# Header with controls
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
header_box.set_margin_start(12)
header_box.set_margin_end(12)
header_box.set_margin_top(8)
header_box.set_margin_bottom(8)
vbox.pack_start(header_box, False, False, 0)
# Log title
log_label = Gtk.Label()
log_label.set_markup("<b>📋 Command Log</b>")
log_label.set_halign(Gtk.Align.START)
header_box.pack_start(log_label, False, False, 0)
# Spacer
spacer = Gtk.Box()
header_box.pack_start(spacer, True, True, 0)
# Auto-scroll toggle
self.autoscroll_switch = Gtk.Switch()
self.autoscroll_switch.set_active(True)
self.autoscroll_switch.connect(
"notify::active", self._on_autoscroll_toggle)
header_box.pack_start(self.autoscroll_switch, False, False, 0)
autoscroll_label = Gtk.Label()
autoscroll_label.set_text("Auto-scroll")
autoscroll_label.set_margin_start(4)
header_box.pack_start(autoscroll_label, False, False, 0)
# Clear button
clear_btn = Gtk.Button(label="Clear")
clear_btn.connect("clicked", self._on_clear_clicked)
header_box.pack_start(clear_btn, False, False, 0)
# Separator
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
vbox.pack_start(separator, False, False, 0)
# Scrolled window for log content
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled.set_min_content_height(150)
scrolled.set_max_content_height(400)
vbox.pack_start(scrolled, True, True, 0)
# Text view for log content
self.text_view = Gtk.TextView()
self.text_view.set_editable(False)
self.text_view.set_cursor_visible(False)
self.text_view.set_wrap_mode(Gtk.WrapMode.WORD)
# Set monospace font
font_desc = Pango.FontDescription("monospace 9")
self.text_view.modify_font(font_desc)
scrolled.add(self.text_view)
# Get text buffer and create tags for different log levels
self.text_buffer = self.text_view.get_buffer()
self._create_text_tags()
# Store reference to scrolled window for auto-scrolling
self.scrolled_window = scrolled
return vbox
def _create_text_tags(self):
"""Create text tags for different log levels."""
# Command tag (bold, blue)
command_tag = self.text_buffer.create_tag("command")
command_tag.set_property("weight", Pango.Weight.BOLD)
command_tag.set_property("foreground", "#0066cc")
# Info tag (default)
info_tag = self.text_buffer.create_tag("info")
# Warning tag (orange)
warning_tag = self.text_buffer.create_tag("warning")
warning_tag.set_property("foreground", "#ff8800")
# Error tag (red, bold)
error_tag = self.text_buffer.create_tag("error")
error_tag.set_property("foreground", "#cc0000")
error_tag.set_property("weight", Pango.Weight.BOLD)
# Debug tag (gray)
debug_tag = self.text_buffer.create_tag("debug")
debug_tag.set_property("foreground", "#666666")
# Timestamp tag (small, gray)
timestamp_tag = self.text_buffer.create_tag("timestamp")
timestamp_tag.set_property("foreground", "#888888")
timestamp_tag.set_property("size", 8 * Pango.SCALE)
def _on_autoscroll_toggle(self, switch, gparam):
"""Handle auto-scroll toggle."""
self.auto_scroll = switch.get_active()
def _on_clear_clicked(self, button):
"""Clear the log content."""
self.text_buffer.set_text("")
def _auto_scroll_to_bottom(self):
"""Scroll to bottom if auto-scroll is enabled."""
if not self.auto_scroll:
return
# Get the end iterator
end_iter = self.text_buffer.get_end_iter()
# Create a mark at the end
mark = self.text_buffer.get_insert()
self.text_buffer.place_cursor(end_iter)
# Scroll to the mark
self.text_view.scroll_mark_onscreen(mark)
def _get_timestamp(self) -> str:
"""Get current timestamp string."""
return time.strftime("%H:%M:%S")
def _trim_log_if_needed(self):
"""Trim log to max_lines if exceeded."""
line_count = self.text_buffer.get_line_count()
if line_count <= self.max_lines:
return
# Calculate how many lines to remove (keep some buffer)
lines_to_remove = line_count - (self.max_lines - 100)
# Get iterator at start
start_iter = self.text_buffer.get_start_iter()
# Move to the line we want to keep
end_iter = self.text_buffer.get_iter_at_line(lines_to_remove)
# Delete the old lines
self.text_buffer.delete(start_iter, end_iter)
def log_message(self, message: str, level: LogLevel = LogLevel.INFO,
command: Optional[str] = None):
"""Add a log message to the view.
Args:
message: The message to log
level: The log level
command: Optional command that generated this message
"""
# Ensure we're on the main thread
GLib.idle_add(self._add_log_message, message, level, command)
def _add_log_message(self, message: str, level: LogLevel, command: Optional[str]):
"""Add log message to buffer (main thread only)."""
timestamp = self._get_timestamp()
# Get end iterator
end_iter = self.text_buffer.get_end_iter()
# Add timestamp
self.text_buffer.insert_with_tags_by_name(
end_iter, f"[{timestamp}] ", "timestamp"
)
# Add command if provided
if command:
end_iter = self.text_buffer.get_end_iter()
self.text_buffer.insert_with_tags_by_name(
end_iter, f"$ {command}\n", "command"
)
# Add the message with appropriate tag
end_iter = self.text_buffer.get_end_iter()
tag_name = level.value.lower()
self.text_buffer.insert_with_tags_by_name(
end_iter, f"{message}\n", tag_name
)
# Trim log if needed
self._trim_log_if_needed()
# Auto-scroll to bottom
self._auto_scroll_to_bottom()
return False # Remove from idle queue
def log_command(self, command: str, output: str = "", error: str = "",
return_code: int = 0):
"""Log a command execution with its output.
Args:
command: The command that was executed
output: Standard output from the command
error: Standard error from the command
return_code: Command return code
"""
# Log the command
self.log_message("", LogLevel.COMMAND, command)
# Log output if present
if output.strip():
for line in output.strip().split('\n'):
self.log_message(line, LogLevel.INFO)
# Log error if present
if error.strip():
for line in error.strip().split('\n'):
self.log_message(f"ERROR: {line}", LogLevel.ERROR)
# Log return code if non-zero
if return_code != 0:
self.log_message(
f"Command exited with code: {return_code}", LogLevel.ERROR)
elif return_code == 0 and (output.strip() or error.strip()):
self.log_message("Command completed successfully", LogLevel.INFO)
def log_info(self, message: str):
"""Log an info message."""
self.log_message(message, LogLevel.INFO)
def log_warning(self, message: str):
"""Log a warning message."""
self.log_message(message, LogLevel.WARNING)
def log_error(self, message: str):
"""Log an error message."""
self.log_message(message, LogLevel.ERROR)
def log_debug(self, message: str):
"""Log a debug message."""
self.log_message(message, LogLevel.DEBUG)
def log_success(self, message: str):
"""Log a success message."""
self.log_message(f"{message}", LogLevel.INFO)
def set_visible(self, visible: bool):
"""Set visibility of the entire view."""
self.widget.set_visible(visible)
def clear(self):
"""Clear all log content."""
self._on_clear_clicked(None)

View File

@@ -1,6 +1,7 @@
from .host_item import HostItem from .host_item import HostItem
from .location_card import ActiveLocationCard, InactiveLocationCard from .location_card import ActiveLocationCard, InactiveLocationCard
from .customer_card import ActiveCustomerCard, InactiveCustomerCard from .active_customer_card import ActiveCustomerCard
from .inactive_customer_card import InactiveCustomerCard
__all__ = [ __all__ = [
'HostItem', 'HostItem',

View File

@@ -0,0 +1,399 @@
from utils import IconLoader
from gi.repository import Gtk
import gi
gi.require_version('Gtk', '3.0')
def escape_markup(text: str) -> str:
"""Escape special characters for Pango markup."""
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
class ActiveCustomerCard:
def __init__(self, customer, callbacks):
self.customer = customer
self.callbacks = callbacks
self.expanded = True # Start expanded by default
self.location_expanded = {} # Track expansion state of each location
# Initialize all locations as expanded
for location in self.customer.locations:
self.location_expanded[location.name] = True
self.widget = self._create_widget()
def _create_widget(self):
# Customer card container
customer_frame = Gtk.Frame()
customer_frame.get_style_context().add_class("card")
customer_frame.set_shadow_type(Gtk.ShadowType.NONE)
customer_vbox = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=4)
customer_frame.add(customer_vbox)
# Customer header row
customer_row = self._create_customer_header()
customer_vbox.pack_start(customer_row, False, False, 0)
# Content container (locations)
self.content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.content_box.set_margin_start(8)
self.content_box.set_margin_end(8)
self.content_box.set_margin_bottom(8)
customer_vbox.pack_start(self.content_box, False, False, 0)
# Add location cards
for location in self.customer.locations:
location_card = self._create_location_card(location)
self.content_box.pack_start(location_card, False, False, 0)
return customer_frame
def _create_customer_header(self):
"""Create the customer header row."""
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.set_margin_start(4)
row.set_margin_end(4)
row.set_margin_top(4)
row.set_margin_bottom(2)
# Expand/collapse arrow
self.expand_button = Gtk.Button()
self.expand_button.set_relief(Gtk.ReliefStyle.NONE)
self.expand_button.set_can_focus(False)
self.expand_button.set_size_request(20, 20)
self._update_expand_button()
self.expand_button.connect("clicked", self._on_expand_toggle)
row.pack_start(self.expand_button, False, False, 0)
# Customer name
customer_label = Gtk.Label()
escaped_name = escape_markup(self.customer.name)
customer_label.set_markup(f"<b>{escaped_name}</b>")
customer_label.set_halign(Gtk.Align.START)
row.pack_start(customer_label, True, True, 0)
# Customer service icons (right side)
self._add_customer_service_icons(row)
return row
def _create_location_card(self, location):
"""Create a location card (subcard within customer card)."""
# Location subcard
location_frame = Gtk.Frame()
location_frame.get_style_context().add_class("card")
location_frame.set_shadow_type(Gtk.ShadowType.NONE)
location_vbox = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=2)
location_frame.add(location_vbox)
# Location header row
location_row = self._create_location_header(location)
location_vbox.pack_start(location_row, False, False, 0)
# Hosts container (collapsible)
location_key = location.name
hosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
hosts_box.set_margin_start(8)
hosts_box.set_margin_end(4)
hosts_box.set_margin_bottom(4)
# Add hosts for this location
if location.hosts:
for host in location.hosts:
host_row = self._create_host_row(host, location)
hosts_box.pack_start(host_row, False, False, 0)
# Add sub-hosts (VMs)
if host.sub_hosts:
for vm in host.sub_hosts:
vm_row = self._create_host_row(
vm, location, is_vm=True)
hosts_box.pack_start(vm_row, False, False, 0)
location_vbox.pack_start(hosts_box, False, False, 0)
# Store reference to hosts_box for expand/collapse
setattr(location_frame, 'hosts_box', hosts_box)
setattr(location_frame, 'location', location)
# Set initial visibility
expanded = self.location_expanded.get(location_key, True)
hosts_box.set_visible(expanded)
return location_frame
def _create_location_header(self, location):
"""Create the location header row."""
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.set_margin_start(4)
row.set_margin_end(4)
row.set_margin_top(4)
row.set_margin_bottom(2)
# Location expand/collapse arrow
expand_btn = Gtk.Button()
expand_btn.set_relief(Gtk.ReliefStyle.NONE)
expand_btn.set_can_focus(False)
expand_btn.set_size_request(20, 20)
# Set initial arrow direction
expanded = self.location_expanded.get(location.name, True)
expand_btn.set_label("" if expanded else "")
# Connect to toggle function
expand_btn.connect(
"clicked", lambda btn: self._toggle_location_expansion(location, btn))
row.pack_start(expand_btn, False, False, 0)
# Location info
location_info = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=0)
# Location name
name_label = Gtk.Label()
escaped_location_name = escape_markup(location.name)
name_label.set_markup(f"<b>{escaped_location_name}</b>")
name_label.set_halign(Gtk.Align.START)
location_info.pack_start(name_label, False, False, 0)
# External addresses (small text)
if location.external_addresses:
addr_text = ", ".join(
location.external_addresses[:2]) # Show first 2
if len(location.external_addresses) > 2:
addr_text += f" (+{len(location.external_addresses) - 2} more)"
addr_label = Gtk.Label()
addr_label.set_markup(f"<small><i>{addr_text}</i></small>")
addr_label.set_halign(Gtk.Align.START)
location_info.pack_start(addr_label, False, False, 0)
row.pack_start(location_info, True, True, 0)
# VPN Status
status_label = Gtk.Label()
if location.connected:
status_label.set_markup(
"<span color='#4caf50'><b>Connected</b></span>")
else:
status_label.set_markup(
"<span color='#999'><b>Disconnected</b></span>")
row.pack_start(status_label, False, False, 0)
# Action icons
self._add_location_action_icons(row, location)
return row
def _create_host_row(self, host, location, is_vm=False):
"""Create a host row with aligned IP addresses."""
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
margin = 16 if not is_vm else 32 # VMs are more indented
row.set_margin_start(margin)
row.set_margin_end(4)
row.set_margin_top(1)
row.set_margin_bottom(1)
# Host icon - custom or fallback to Material Icons
icon_widget = IconLoader.get_host_icon_widget(host, size=20)
icon_container = Gtk.Box()
icon_container.set_size_request(24, 24) # Fixed size for alignment
icon_container.set_center_widget(icon_widget)
row.pack_start(icon_container, False, False, 0)
# Host name (fixed width for alignment)
escaped_host_name = escape_markup(host.name)
name_markup = f"<i>{escaped_host_name}</i>" if is_vm else f"<b>{escaped_host_name}</b>"
name_label = Gtk.Label()
name_label.set_markup(name_markup)
name_label.set_halign(Gtk.Align.START)
name_label.set_size_request(150, -1) # Fixed width to align IPs
row.pack_start(name_label, False, False, 0)
# IP address (aligned in middle)
ip_label = Gtk.Label()
ip_label.set_markup(f"<small>{host.get_ip_display()}</small>")
ip_label.set_size_request(120, -1) # Fixed width for alignment
ip_label.set_halign(Gtk.Align.CENTER)
ip_label.set_tooltip_text(", ".join(host.get_all_ips()) if len(
host.ip_addresses) > 1 else host.get_primary_ip())
row.pack_start(ip_label, False, False, 0)
# Spacer to push service icons to the right
spacer = Gtk.Box()
row.pack_start(spacer, True, True, 0)
# Service action icons
self._add_host_service_icons(row, host, location)
return row
def _add_customer_service_icons(self, row):
"""Add customer service icons to the right side."""
# Service type to icon mapping
service_icons = {
'Email & Office': '📧', # O365
'Phone System': '📞', # PBX
'CRM': '👥', # Salesforce
'Email': '📧',
'Office': '📄',
}
# Add icons for each service
for service in self.customer.services[:4]: # Limit to 4 icons
icon = service_icons.get(service.service_type, '🌐')
btn = Gtk.Button()
btn.set_label(icon)
btn.set_relief(Gtk.ReliefStyle.NONE)
btn.set_can_focus(False)
btn.set_size_request(24, 24)
btn.set_tooltip_text(f"Open {service.name}")
btn.connect("clicked", lambda b,
s=service: self.callbacks['open_customer_service'](s))
row.pack_start(btn, False, False, 0)
# Menu button (always last)
menu_btn = self._create_menu_button()
row.pack_start(menu_btn, False, False, 0)
def _add_location_action_icons(self, row, location):
"""Add location action icons."""
# Connection toggle
connect_icon = "🔌" if not location.connected else "🔓"
connect_btn = Gtk.Button()
connect_btn.set_label(connect_icon)
connect_btn.set_relief(Gtk.ReliefStyle.NONE)
connect_btn.set_can_focus(False)
connect_btn.set_size_request(24, 24)
tooltip = "Connect to VPN" if not location.connected else "Disconnect VPN"
connect_btn.set_tooltip_text(tooltip)
connect_btn.connect(
"clicked", lambda b: self.callbacks['toggle_connection'](location))
row.pack_start(connect_btn, False, False, 0)
# Refresh/reload
refresh_btn = Gtk.Button()
refresh_btn.set_label("🔄")
refresh_btn.set_relief(Gtk.ReliefStyle.NONE)
refresh_btn.set_can_focus(False)
refresh_btn.set_size_request(24, 24)
refresh_btn.set_tooltip_text("Refresh connection")
row.pack_start(refresh_btn, False, False, 0)
# Menu
menu_btn = self._create_menu_button()
row.pack_start(menu_btn, False, False, 0)
def _add_host_service_icons(self, row, host, location):
"""Add host service icons with reachability check."""
# Service type to Material Icons mapping
# Icon names from: https://fonts.google.com/icons
service_icons = {
'SSH': 'terminal', # Terminal icon for SSH
'Web GUI': 'language', # Globe icon for web
'RDP': 'desktop_windows', # Desktop icon for RDP
'VNC': 'monitor', # Monitor icon for VNC
'SMB': 'folder_shared', # Shared folder for SMB
'Database': 'storage', # Database/storage icon
'FTP': 'cloud_upload' # Upload icon for FTP
}
# Add icons for services
for service in host.services[:3]: # Limit to 3 service icons
# Default to settings icon
icon = service_icons.get(service.service_type.value, 'settings')
# Check if service is reachable
is_reachable = location.is_service_reachable(host, service)
is_external = location.get_external_url_for_service(
host, service) is not None
btn = Gtk.Button()
btn.set_label(icon) # Material Icons uses ligatures
btn.set_relief(Gtk.ReliefStyle.NONE)
btn.set_can_focus(False)
btn.set_size_request(24, 24)
# Apply color styling based on reachability
if is_reachable:
# Green for accessible
btn.get_style_context().add_class("service-icon-accessible")
if is_external and not location.connected:
external_url = location.get_external_url_for_service(
host, service)
btn.set_tooltip_text(
f"{service.service_type.value}: {service.name}\nExternal: {external_url}")
else:
btn.set_tooltip_text(
f"{service.service_type.value}: {service.name}")
else:
# Red for not accessible
btn.get_style_context().add_class("service-icon-inaccessible")
btn.set_tooltip_text(
f"{service.service_type.value}: {service.name}\nNot reachable (VPN disconnected)")
# Enable/disable based on reachability
btn.set_sensitive(is_reachable)
# Connect click handler only if reachable
if is_reachable:
btn.connect("clicked", lambda b,
s=service: self.callbacks['open_service'](s))
row.pack_start(btn, False, False, 0)
# Menu button
menu_btn = self._create_menu_button()
row.pack_start(menu_btn, False, False, 0)
def _create_menu_button(self):
"""Create a menu button with empty popup."""
menu_btn = Gtk.MenuButton()
menu_btn.set_label("") # Three dots menu
menu_btn.set_relief(Gtk.ReliefStyle.NONE)
menu_btn.set_can_focus(False)
menu_btn.set_size_request(24, 24)
# Create empty menu for now
menu = Gtk.Menu()
placeholder_item = Gtk.MenuItem(label="(Empty menu)")
placeholder_item.set_sensitive(False)
menu.append(placeholder_item)
menu.show_all()
menu_btn.set_popup(menu)
return menu_btn
def _update_expand_button(self):
"""Update the expand button arrow direction."""
if self.expanded:
self.expand_button.set_label("")
else:
self.expand_button.set_label("")
def _toggle_location_expansion(self, location, button):
"""Toggle the expansion state of a specific location."""
location_key = location.name
current_state = self.location_expanded.get(location_key, True)
new_state = not current_state
self.location_expanded[location_key] = new_state
# Update button arrow
button.set_label("" if new_state else "")
# Find the location card and toggle its hosts box visibility
for widget in self.content_box.get_children():
if hasattr(widget, 'location') and widget.location.name == location_key:
hosts_box = getattr(widget, 'hosts_box', None)
if hosts_box:
hosts_box.set_visible(new_state)
break
def _on_expand_toggle(self, button):
"""Toggle the expanded state."""
self.expanded = not self.expanded
self._update_expand_button()
self.content_box.set_visible(self.expanded)

View File

@@ -1,114 +0,0 @@
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from .location_card import ActiveLocationCard, InactiveLocationCard
class ActiveCustomerCard:
def __init__(self, customer, callbacks):
self.customer = customer
self.callbacks = callbacks
self.widget = self._create_widget()
def _create_widget(self):
# GNOME-style card container
card_frame = Gtk.Frame()
card_frame.get_style_context().add_class("card")
card_frame.set_shadow_type(Gtk.ShadowType.NONE) # Shadow handled by CSS
card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
card_frame.add(card_vbox)
# Customer header
customer_label = Gtk.Label()
customer_label.set_markup(f"<b><big>🏢 {self.customer.name}</big></b>")
customer_label.set_halign(Gtk.Align.START)
card_vbox.pack_start(customer_label, False, False, 0)
# Customer services section
if self.customer.services:
services_label = Gtk.Label()
services_label.set_markup("<b>Cloud Services</b>")
services_label.set_halign(Gtk.Align.START)
services_label.set_margin_top(8)
card_vbox.pack_start(services_label, False, False, 0)
# Services box with indent
services_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
services_box.set_margin_start(16)
services_box.set_margin_bottom(8)
card_vbox.pack_start(services_box, False, False, 0)
for service in self.customer.services:
service_btn = Gtk.Button(label=service.name)
service_btn.get_style_context().add_class("suggested-action")
service_btn.connect("clicked", lambda btn, s=service: self.callbacks['open_customer_service'](s))
services_box.pack_start(service_btn, False, False, 0)
# Locations section
for i, location in enumerate(self.customer.locations):
if i > 0: # Add separator between locations
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
separator.set_margin_top(8)
separator.set_margin_bottom(8)
card_vbox.pack_start(separator, False, False, 0)
location_card = ActiveLocationCard(location, self.customer.name, self.callbacks)
card_vbox.pack_start(location_card.widget, False, False, 0)
return card_frame
class InactiveCustomerCard:
def __init__(self, customer, callbacks):
self.customer = customer
self.callbacks = callbacks
self.widget = self._create_widget()
def _create_widget(self):
# GNOME-style card container
card_frame = Gtk.Frame()
card_frame.get_style_context().add_class("card")
card_frame.set_shadow_type(Gtk.ShadowType.NONE) # Shadow handled by CSS
card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
card_frame.add(card_vbox)
# Customer header - muted
customer_label = Gtk.Label()
customer_label.set_markup(f"<span alpha='60%'><b><big>🏢 {self.customer.name}</big></b></span>")
customer_label.set_halign(Gtk.Align.START)
card_vbox.pack_start(customer_label, False, False, 0)
# Customer services section - list format for inactive
if self.customer.services:
services_label = Gtk.Label()
services_label.set_markup("<span alpha='60%'><b>Cloud Services</b></span>")
services_label.set_halign(Gtk.Align.START)
services_label.set_margin_top(8)
card_vbox.pack_start(services_label, False, False, 0)
# Services list with indent
services_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
services_vbox.set_margin_start(16)
services_vbox.set_margin_bottom(8)
card_vbox.pack_start(services_vbox, False, False, 0)
for service in self.customer.services:
service_label = Gtk.Label()
service_label.set_markup(f"<span alpha='60%'><small>• {service.name} ({service.service_type})</small></span>")
service_label.set_halign(Gtk.Align.START)
services_vbox.pack_start(service_label, False, False, 0)
# Locations section
for i, location in enumerate(self.customer.locations):
if i > 0: # Add separator between locations
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
separator.set_margin_top(8)
separator.set_margin_bottom(8)
card_vbox.pack_start(separator, False, False, 0)
location_card = InactiveLocationCard(location, self.customer.name, self.callbacks)
card_vbox.pack_start(location_card.widget, False, False, 0)
return card_frame

View File

@@ -2,11 +2,18 @@ import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk from gi.repository import Gtk
from models import ServiceType, HostType from models import ServiceType, HostType
from utils import IconLoader
def escape_markup(text: str) -> str:
"""Escape special characters for Pango markup."""
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
class HostItem: class HostItem:
def __init__(self, host, open_service_callback): def __init__(self, host, location, open_service_callback):
self.host = host self.host = host
self.location = location
self.open_service_callback = open_service_callback self.open_service_callback = open_service_callback
self.widget = self._create_widget() self.widget = self._create_widget()
@@ -18,20 +25,12 @@ class HostItem:
host_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) host_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
host_box.pack_start(host_header, False, False, 0) host_box.pack_start(host_header, False, False, 0)
# Host type icon # Host icon - custom or fallback to Material Icons
type_icons = { icon_widget = IconLoader.get_host_icon_widget(self.host, size=24)
HostType.LINUX: "🐧", icon_container = Gtk.Box()
HostType.WINDOWS: "🪟", icon_container.set_size_request(32, 24) # Fixed size
HostType.WINDOWS_SERVER: "🖥️", icon_container.set_center_widget(icon_widget)
HostType.PROXMOX: "📦", host_header.pack_start(icon_container, False, False, 0)
HostType.ESXI: "📦",
HostType.ROUTER: "🌐",
HostType.SWITCH: "🔗"
}
icon = type_icons.get(self.host.host_type, "💻")
icon_label = Gtk.Label(label=icon)
host_header.pack_start(icon_label, False, False, 0)
# Host details - compact single line # Host details - compact single line
details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
@@ -39,8 +38,14 @@ class HostItem:
# Host name with IP inline # Host name with IP inline
name_label = Gtk.Label() name_label = Gtk.Label()
name_label.set_markup(f"<b>{self.host.name}</b> <small>({self.host.host_type.value}) - <tt>{self.host.ip_address}</tt></small>") ip_display = self.host.get_ip_display()
escaped_host_name = escape_markup(self.host.name)
escaped_host_type = escape_markup(self.host.host_type.value)
escaped_ip_display = escape_markup(ip_display)
name_label.set_markup(f"<b>{escaped_host_name}</b> <small>({escaped_host_type}) - <tt>{escaped_ip_display}</tt></small>")
name_label.set_halign(Gtk.Align.START) name_label.set_halign(Gtk.Align.START)
if len(self.host.ip_addresses) > 1:
name_label.set_tooltip_text(f"All IPs: {', '.join(self.host.get_all_ips())}")
details_vbox.pack_start(name_label, False, False, 0) details_vbox.pack_start(name_label, False, False, 0)
# Services section - compact button row # Services section - compact button row
@@ -52,9 +57,35 @@ class HostItem:
for service in self.host.services: for service in self.host.services:
if service.service_type in [ServiceType.WEB_GUI, ServiceType.SSH, ServiceType.RDP]: # Only show launchable services if service.service_type in [ServiceType.WEB_GUI, ServiceType.SSH, ServiceType.RDP]: # Only show launchable services
# Check if service is reachable
is_reachable = self.location.is_service_reachable(self.host, service)
is_external = self.location.get_external_url_for_service(self.host, service) is not None
service_btn = Gtk.Button(label=service.service_type.value) service_btn = Gtk.Button(label=service.service_type.value)
# Apply color-based styling
if is_reachable:
# Green styling for accessible services
service_btn.get_style_context().add_class("suggested-action") service_btn.get_style_context().add_class("suggested-action")
service_btn.set_name("service-btn-accessible")
if is_external and not self.location.connected:
external_url = self.location.get_external_url_for_service(self.host, service)
service_btn.set_tooltip_text(f"Open {service.name}\nExternal: {external_url}")
else:
service_btn.set_tooltip_text(f"Open {service.name}")
else:
# Red styling for inaccessible services
service_btn.get_style_context().add_class("destructive-action")
service_btn.set_name("service-btn-inaccessible")
service_btn.set_tooltip_text(f"{service.name} - Not reachable (VPN disconnected)")
# Enable/disable based on reachability
service_btn.set_sensitive(is_reachable)
# Connect handler only if reachable
if is_reachable:
service_btn.connect("clicked", lambda btn, s=service: self._on_service_clicked(s)) service_btn.connect("clicked", lambda btn, s=service: self._on_service_clicked(s))
services_box.pack_start(service_btn, False, False, 0) services_box.pack_start(service_btn, False, False, 0)
# Sub-hosts (VMs) section # Sub-hosts (VMs) section
@@ -71,7 +102,7 @@ class HostItem:
host_box.pack_start(subhosts_box, False, False, 0) host_box.pack_start(subhosts_box, False, False, 0)
for subhost in self.host.sub_hosts: for subhost in self.host.sub_hosts:
subhost_item = HostItem(subhost, self.open_service_callback) subhost_item = HostItem(subhost, self.location, self.open_service_callback)
subhosts_box.pack_start(subhost_item.widget, False, False, 0) subhosts_box.pack_start(subhost_item.widget, False, False, 0)
return host_box return host_box

View File

@@ -0,0 +1,118 @@
from .location_card import InactiveLocationCard
from gi.repository import Gtk
import gi
gi.require_version('Gtk', '3.0')
def escape_markup(text: str) -> str:
"""Escape special characters for Pango markup."""
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
class InactiveCustomerCard:
def __init__(self, customer, callbacks):
self.customer = customer
self.callbacks = callbacks
self.expanded = False # Start collapsed by default for inactive
self.widget = self._create_widget()
def _create_widget(self):
# GNOME-style card container
card_frame = Gtk.Frame()
card_frame.get_style_context().add_class("card")
card_frame.set_shadow_type(
Gtk.ShadowType.NONE) # Shadow handled by CSS
card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
card_frame.add(card_vbox)
# Customer header with expand/collapse button - muted
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
card_vbox.pack_start(header_box, False, False, 0)
# Expand/collapse arrow button
self.expand_button = Gtk.Button()
self.expand_button.set_relief(Gtk.ReliefStyle.NONE)
self.expand_button.set_can_focus(False)
self._update_expand_button()
self.expand_button.connect("clicked", self._on_expand_toggle)
header_box.pack_start(self.expand_button, False, False, 0)
# Customer name - muted
customer_label = Gtk.Label()
escaped_name = escape_markup(self.customer.name)
customer_label.set_markup(
f"<span alpha='60%'><b><big>🏢 {escaped_name}</big></b></span>")
customer_label.set_halign(Gtk.Align.START)
header_box.pack_start(customer_label, False, False, 0)
# Location count badge
inactive_count = len(self.customer.locations)
if inactive_count > 0:
count_label = Gtk.Label()
count_label.set_markup(
f"<span alpha='60%'><small><b>({inactive_count})</b></small></span>")
header_box.pack_start(count_label, False, False, 0)
# Content container (collapsible)
self.content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=12)
self.content_box.set_visible(self.expanded) # Start hidden
card_vbox.pack_start(self.content_box, False, False, 0)
# Customer services section - list format for inactive
if self.customer.services:
services_label = Gtk.Label()
services_label.set_markup(
"<span alpha='60%'><b>Cloud Services</b></span>")
services_label.set_halign(Gtk.Align.START)
services_label.set_margin_top(8)
self.content_box.pack_start(services_label, False, False, 0)
# Services list with indent
services_vbox = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=2)
services_vbox.set_margin_start(16)
services_vbox.set_margin_bottom(8)
self.content_box.pack_start(services_vbox, False, False, 0)
for service in self.customer.services:
service_label = Gtk.Label()
# Escape special characters in markup text
escaped_name = escape_markup(service.name)
escaped_type = escape_markup(service.service_type)
service_label.set_markup(
f"<span alpha='60%'><small>• {escaped_name} ({escaped_type})</small></span>")
service_label.set_halign(Gtk.Align.START)
services_vbox.pack_start(service_label, False, False, 0)
# Locations section
for i, location in enumerate(self.customer.locations):
if i > 0: # Add separator between locations
separator = Gtk.Separator(
orientation=Gtk.Orientation.HORIZONTAL)
separator.set_margin_top(8)
separator.set_margin_bottom(8)
self.content_box.pack_start(separator, False, False, 0)
location_card = InactiveLocationCard(
location, self.customer.name, self.callbacks)
self.content_box.pack_start(location_card.widget, False, False, 0)
# Show all content in the box (but box itself may be hidden)
self.content_box.show_all()
return card_frame
def _update_expand_button(self):
"""Update the expand button arrow direction."""
if self.expanded:
self.expand_button.set_label("")
else:
self.expand_button.set_label("")
def _on_expand_toggle(self, button):
"""Toggle the expanded state."""
self.expanded = not self.expanded
self._update_expand_button()
self.content_box.set_visible(self.expanded)

View File

@@ -1,8 +1,13 @@
from models import VPNType
from .host_item import HostItem
from gi.repository import Gtk
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
from .host_item import HostItem
from models import VPNType def escape_markup(text: str) -> str:
"""Escape special characters for Pango markup."""
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
class ActiveLocationCard: class ActiveLocationCard:
@@ -14,10 +19,12 @@ class ActiveLocationCard:
def _create_widget(self): def _create_widget(self):
# Clean card layout - just a box with proper spacing # Clean card layout - just a box with proper spacing
location_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) location_vbox = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=8)
# Location header with controls # Location header with controls
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) header_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
location_vbox.pack_start(header_box, False, False, 0) location_vbox.pack_start(header_box, False, False, 0)
# Location info # Location info
@@ -26,11 +33,12 @@ class ActiveLocationCard:
# Location name with VPN type # Location name with VPN type
location_label = Gtk.Label() location_label = Gtk.Label()
location_label.set_markup(f"<b>📍 {self.location.name}</b>") escaped_location_name = escape_markup(self.location.name)
location_label.set_markup(f"<b>📍 {escaped_location_name}</b>")
location_label.set_halign(Gtk.Align.START) location_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(location_label, False, False, 0) info_vbox.pack_start(location_label, False, False, 0)
# VPN type # VPN type and external address
vpn_icons = { vpn_icons = {
VPNType.OPENVPN: "🔒", VPNType.OPENVPN: "🔒",
VPNType.WIREGUARD: "", VPNType.WIREGUARD: "",
@@ -38,20 +46,46 @@ class ActiveLocationCard:
} }
vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑") vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑")
type_text = f"{vpn_icon} {self.location.vpn_type.value} VPN"
if self.location.external_addresses:
if len(self.location.external_addresses) == 1:
type_text += f" • 🌐 {self.location.external_addresses[0]}"
else:
type_text += f" • 🌐 {len(self.location.external_addresses)} endpoints"
type_label = Gtk.Label() type_label = Gtk.Label()
type_label.set_markup(f"<small>{vpn_icon} {self.location.vpn_type.value} VPN</small>") type_label.set_markup(f"<small>{type_text}</small>")
type_label.set_halign(Gtk.Align.START) type_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(type_label, False, False, 0) info_vbox.pack_start(type_label, False, False, 0)
# External addresses and networks if available
if self.location.external_addresses and len(self.location.external_addresses) > 1:
# Show full list if more than one
addresses_text = "🌐 External: " + \
", ".join(self.location.external_addresses)
addresses_label = Gtk.Label()
addresses_label.set_markup(f"<small>{addresses_text}</small>")
addresses_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(addresses_label, False, False, 0)
if self.location.networks:
networks_text = "📡 Networks: " + ", ".join(self.location.networks)
networks_label = Gtk.Label()
networks_label.set_markup(f"<small>{networks_text}</small>")
networks_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(networks_label, False, False, 0)
# Status and controls # Status and controls
controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) controls_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
header_box.pack_end(controls_box, False, False, 0) header_box.pack_end(controls_box, False, False, 0)
# Status # Status
status_text = "● Connected" if self.location.connected else "○ Disconnected" status_text = "● Connected" if self.location.connected else "○ Disconnected"
status_color = "#4caf50" if self.location.connected else "#999" status_color = "#4caf50" if self.location.connected else "#999"
status_label = Gtk.Label() status_label = Gtk.Label()
status_label.set_markup(f"<small><span color='{status_color}'>{status_text}</span></small>") status_label.set_markup(
f"<small><span color='{status_color}'>{status_text}</span></small>")
controls_box.pack_start(status_label, False, False, 0) controls_box.pack_start(status_label, False, False, 0)
# Connect/Disconnect button # Connect/Disconnect button
@@ -80,12 +114,14 @@ class ActiveLocationCard:
location_vbox.pack_start(hosts_label, False, False, 0) location_vbox.pack_start(hosts_label, False, False, 0)
# Hosts box with indent # Hosts box with indent
hosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) hosts_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=8)
hosts_box.set_margin_start(16) hosts_box.set_margin_start(16)
location_vbox.pack_start(hosts_box, False, False, 0) location_vbox.pack_start(hosts_box, False, False, 0)
for host in self.location.hosts: for host in self.location.hosts:
host_item = HostItem(host, self.callbacks['open_service']) host_item = HostItem(host, self.location,
self.callbacks['open_service'])
hosts_box.pack_start(host_item.widget, False, False, 0) hosts_box.pack_start(host_item.widget, False, False, 0)
return location_vbox return location_vbox
@@ -94,7 +130,8 @@ class ActiveLocationCard:
self.callbacks['toggle_connection'](self.location) self.callbacks['toggle_connection'](self.location)
def _on_deactivate_clicked(self, button): def _on_deactivate_clicked(self, button):
self.callbacks['deactivate_location'](self.location, self.customer_name) self.callbacks['deactivate_location'](
self.location, self.customer_name)
class InactiveLocationCard: class InactiveLocationCard:
@@ -106,7 +143,8 @@ class InactiveLocationCard:
def _create_widget(self): def _create_widget(self):
# Clean horizontal layout # Clean horizontal layout
location_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) location_hbox = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
# Location info # Location info
info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
@@ -114,11 +152,12 @@ class InactiveLocationCard:
# Location name # Location name
location_label = Gtk.Label() location_label = Gtk.Label()
location_label.set_markup(f"<b>📍 {self.location.name}</b>") escaped_location_name = escape_markup(self.location.name)
location_label.set_markup(f"<b>📍 {escaped_location_name}</b>")
location_label.set_halign(Gtk.Align.START) location_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(location_label, False, False, 0) info_vbox.pack_start(location_label, False, False, 0)
# VPN type and host count # VPN type, external address and host count
vpn_icons = { vpn_icons = {
VPNType.OPENVPN: "🔒", VPNType.OPENVPN: "🔒",
VPNType.WIREGUARD: "", VPNType.WIREGUARD: "",
@@ -127,8 +166,18 @@ class InactiveLocationCard:
vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑") vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑")
host_count = len(self.location.hosts) host_count = len(self.location.hosts)
details_text = f"{vpn_icon} {self.location.vpn_type.value} VPN • {host_count} hosts"
if self.location.external_addresses:
if len(self.location.external_addresses) == 1:
details_text += f" • 🌐 {self.location.external_addresses[0]}"
else:
details_text += f" • 🌐 {len(self.location.external_addresses)} endpoints"
if self.location.networks:
network_count = len(self.location.networks)
details_text += f"{network_count} network{'s' if network_count > 1 else ''}"
details_label = Gtk.Label() details_label = Gtk.Label()
details_label.set_markup(f"<small>{vpn_icon} {self.location.vpn_type.value} VPN • {host_count} hosts</small>") details_label.set_markup(f"<small>{details_text}</small>")
details_label.set_halign(Gtk.Align.START) details_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(details_label, False, False, 0) info_vbox.pack_start(details_label, False, False, 0)
@@ -150,7 +199,9 @@ class InactiveLocationCard:
return location_hbox return location_hbox
def _on_activate_clicked(self, button): def _on_activate_clicked(self, button):
self.callbacks['set_location_active'](self.location, self.customer_name) self.callbacks['set_location_active'](
self.location, self.customer_name)
def _on_set_current_clicked(self, button): def _on_set_current_clicked(self, button):
self.callbacks['set_current_location'](self.location, self.customer_name) self.callbacks['set_current_location'](
self.location, self.customer_name)