sshplex 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sshplex/__init__.py +4 -0
- sshplex/cli.py +103 -0
- sshplex/config-template.yaml +35 -0
- sshplex/lib/__init__.py +1 -0
- sshplex/lib/config.py +196 -0
- sshplex/lib/logger.py +54 -0
- sshplex/lib/multiplexer/__init__.py +1 -0
- sshplex/lib/multiplexer/base.py +48 -0
- sshplex/lib/multiplexer/tmux.py +227 -0
- sshplex/lib/sot/__init__.py +1 -0
- sshplex/lib/sot/base.py +57 -0
- sshplex/lib/sot/netbox.py +174 -0
- sshplex/lib/ssh/__init__.py +1 -0
- sshplex/lib/ssh/connection.py +1 -0
- sshplex/lib/ssh/manager.py +1 -0
- sshplex/lib/ui/__init__.py +1 -0
- sshplex/lib/ui/host_selector.py +492 -0
- sshplex/lib/ui/session_manager.py +500 -0
- sshplex/main.py +182 -0
- sshplex/populate_examples.py +0 -0
- sshplex/sshplex_connector.py +113 -0
- sshplex-1.0.0.dist-info/METADATA +321 -0
- sshplex-1.0.0.dist-info/RECORD +27 -0
- sshplex-1.0.0.dist-info/WHEEL +5 -0
- sshplex-1.0.0.dist-info/entry_points.txt +3 -0
- sshplex-1.0.0.dist-info/licenses/LICENSE +201 -0
- sshplex-1.0.0.dist-info/top_level.txt +1 -0
sshplex/__init__.py
ADDED
sshplex/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""CLI debug interface for SSHplex (for pip-installed package)"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import argparse
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .lib.config import load_config
|
|
8
|
+
from .lib.logger import setup_logging, get_logger
|
|
9
|
+
from .lib.sot.netbox import NetBoxProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> int:
|
|
13
|
+
"""CLI debug entry point for installed SSHplex package."""
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
# Parse command line arguments
|
|
17
|
+
parser = argparse.ArgumentParser(description="SSHplex CLI: Debug interface for NetBox connectivity testing.")
|
|
18
|
+
parser.add_argument('--config', type=str, default=None, help='Path to the configuration file (default: ~/.config/sshplex/sshplex.yaml)')
|
|
19
|
+
parser.add_argument('--version', action='version', version='SSHplex 1.0.0')
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
|
|
22
|
+
# Load configuration (will use default path if none specified)
|
|
23
|
+
print("SSHplex CLI Debug Mode - Loading configuration...")
|
|
24
|
+
config = load_config(args.config)
|
|
25
|
+
|
|
26
|
+
# Setup logging
|
|
27
|
+
setup_logging(
|
|
28
|
+
log_level=config.logging.level,
|
|
29
|
+
log_file=config.logging.file,
|
|
30
|
+
enabled=config.logging.enabled
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = get_logger()
|
|
34
|
+
logger.info("SSHplex CLI debug mode started")
|
|
35
|
+
|
|
36
|
+
return debug_mode(config, logger)
|
|
37
|
+
|
|
38
|
+
except FileNotFoundError as e:
|
|
39
|
+
print(f"Error: {e}")
|
|
40
|
+
print("Please ensure config.yaml exists and is properly configured")
|
|
41
|
+
print("Note: This is the CLI debug interface. For the full TUI application,")
|
|
42
|
+
print("run the main sshplex.py script from the source repository.")
|
|
43
|
+
return 1
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
print(f"Configuration Error: {e}")
|
|
46
|
+
return 1
|
|
47
|
+
except KeyboardInterrupt:
|
|
48
|
+
print("\nSSHplex CLI interrupted by user")
|
|
49
|
+
return 1
|
|
50
|
+
except Exception as e:
|
|
51
|
+
print(f"Unexpected error: {e}")
|
|
52
|
+
return 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def debug_mode(config: Any, logger: Any) -> int:
|
|
56
|
+
"""Run debug mode - NetBox connection and host listing test."""
|
|
57
|
+
logger.info("Running CLI debug mode - NetBox connectivity test")
|
|
58
|
+
|
|
59
|
+
# Initialize NetBox provider
|
|
60
|
+
logger.info("Initializing NetBox provider")
|
|
61
|
+
netbox = NetBoxProvider(
|
|
62
|
+
url=config.netbox.url,
|
|
63
|
+
token=config.netbox.token,
|
|
64
|
+
verify_ssl=config.netbox.verify_ssl,
|
|
65
|
+
timeout=config.netbox.timeout
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Test connection
|
|
69
|
+
logger.info("Testing NetBox connection...")
|
|
70
|
+
if not netbox.connect():
|
|
71
|
+
logger.error("Failed to connect to NetBox")
|
|
72
|
+
print("ā Failed to connect to NetBox")
|
|
73
|
+
print("Check your configuration and network connectivity")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
print("ā
Successfully connected to NetBox")
|
|
77
|
+
|
|
78
|
+
# Retrieve VMs with filters
|
|
79
|
+
logger.info("Retrieving VMs from NetBox...")
|
|
80
|
+
hosts = netbox.get_hosts(filters=config.netbox.default_filters)
|
|
81
|
+
|
|
82
|
+
# Display results
|
|
83
|
+
if hosts:
|
|
84
|
+
logger.info(f"Successfully retrieved {len(hosts)} VMs")
|
|
85
|
+
print(f"\nš Found {len(hosts)} hosts matching filters:")
|
|
86
|
+
print("-" * 60)
|
|
87
|
+
for i, host in enumerate(hosts, 1):
|
|
88
|
+
status = host.metadata.get('status', 'unknown')
|
|
89
|
+
print(f"{i:3d}. {host.name:<30} {host.ip:<15} [{status}]")
|
|
90
|
+
print("-" * 60)
|
|
91
|
+
else:
|
|
92
|
+
logger.warning("No VMs found matching the filters")
|
|
93
|
+
print("ā ļø No hosts found matching the configured filters")
|
|
94
|
+
print("Check your NetBox filters in the configuration")
|
|
95
|
+
|
|
96
|
+
logger.info("SSHplex CLI debug mode completed successfully")
|
|
97
|
+
print(f"\nā
CLI debug mode completed successfully")
|
|
98
|
+
print("Note: For the full TUI interface, run the main application")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
sys.exit(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SSHplex Configuration - Phase 2 with tmux support
|
|
2
|
+
sshplex:
|
|
3
|
+
version: "1.0.0"
|
|
4
|
+
session_prefix: "sshplex"
|
|
5
|
+
|
|
6
|
+
netbox:
|
|
7
|
+
url: "https://netbox.lan/"
|
|
8
|
+
token: "CHANGE_TOKEN_HERE"
|
|
9
|
+
verify_ssl: false
|
|
10
|
+
timeout: 30
|
|
11
|
+
default_filters:
|
|
12
|
+
status: "active"
|
|
13
|
+
role: "virtual-machine"
|
|
14
|
+
has_primary_ip: "true"
|
|
15
|
+
|
|
16
|
+
ssh:
|
|
17
|
+
username: "admin"
|
|
18
|
+
key_path: "~/.ssh/id_ed25519"
|
|
19
|
+
timeout: 10
|
|
20
|
+
port: 22
|
|
21
|
+
|
|
22
|
+
tmux:
|
|
23
|
+
layout: "tiled" # tiled, even-horizontal, even-vertical
|
|
24
|
+
broadcast: false # Start with broadcast off
|
|
25
|
+
window_name: "sshplex"
|
|
26
|
+
|
|
27
|
+
ui:
|
|
28
|
+
show_log_panel: false
|
|
29
|
+
log_panel_height: 20
|
|
30
|
+
table_columns: ["name", "ip", "cluster", "tags", "description"]
|
|
31
|
+
|
|
32
|
+
logging:
|
|
33
|
+
enabled: false
|
|
34
|
+
level: "DEBUG" # DEBUG, INFO, WARNING, ERROR
|
|
35
|
+
file: "logs/sshplex.log"
|
sshplex/lib/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# The logs directory is intentionally left blank.
|
sshplex/lib/config.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""SSHplex configuration management with pydantic validation - Phase 3."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
import yaml
|
|
6
|
+
import shutil
|
|
7
|
+
import os
|
|
8
|
+
from pydantic import BaseModel, Field, validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SSHplexConfig(BaseModel):
|
|
12
|
+
"""SSHplex main configuration."""
|
|
13
|
+
version: str = "1.0.0"
|
|
14
|
+
session_prefix: str = "sshplex"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NetBoxConfig(BaseModel):
|
|
18
|
+
"""NetBox connection configuration."""
|
|
19
|
+
url: str = Field(..., description="NetBox instance URL")
|
|
20
|
+
token: str = Field(..., description="NetBox API token")
|
|
21
|
+
verify_ssl: bool = True
|
|
22
|
+
timeout: int = 30
|
|
23
|
+
default_filters: Dict[str, str] = Field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
@validator('url')
|
|
26
|
+
def validate_url(cls, v: str) -> str:
|
|
27
|
+
"""Validate NetBox URL format."""
|
|
28
|
+
if not v.startswith(('http://', 'https://')):
|
|
29
|
+
raise ValueError('NetBox URL must start with http:// or https://')
|
|
30
|
+
return v
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LoggingConfig(BaseModel):
|
|
34
|
+
"""Logging configuration."""
|
|
35
|
+
enabled: bool = True
|
|
36
|
+
level: str = "INFO"
|
|
37
|
+
file: str = "logs/sshplex.log"
|
|
38
|
+
|
|
39
|
+
@validator('level')
|
|
40
|
+
def validate_level(cls, v: str) -> str:
|
|
41
|
+
"""Validate logging level."""
|
|
42
|
+
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR"]
|
|
43
|
+
if v.upper() not in valid_levels:
|
|
44
|
+
raise ValueError(f'Log level must be one of: {valid_levels}')
|
|
45
|
+
return v.upper()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UIConfig(BaseModel):
|
|
49
|
+
"""User interface configuration."""
|
|
50
|
+
show_log_panel: bool = True
|
|
51
|
+
log_panel_height: int = 20 # Percentage of screen height
|
|
52
|
+
table_columns: list = Field(default_factory=lambda: ["name", "ip", "cluster", "role", "tags"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SSHConfig(BaseModel):
|
|
56
|
+
"""SSH connection configuration."""
|
|
57
|
+
username: str = Field(default="admin", description="Default SSH username")
|
|
58
|
+
key_path: str = Field(default="~/.ssh/id_rsa", description="Path to SSH private key")
|
|
59
|
+
timeout: int = 10
|
|
60
|
+
port: int = 22
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TmuxConfig(BaseModel):
|
|
64
|
+
"""tmux configuration."""
|
|
65
|
+
layout: str = "tiled" # tiled, even-horizontal, even-vertical
|
|
66
|
+
broadcast: bool = False # Start with broadcast off
|
|
67
|
+
window_name: str = "sshplex"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Config(BaseModel):
|
|
71
|
+
"""Main SSHplex configuration model."""
|
|
72
|
+
sshplex: SSHplexConfig = Field(default_factory=SSHplexConfig)
|
|
73
|
+
netbox: NetBoxConfig
|
|
74
|
+
ssh: SSHConfig = Field(default_factory=SSHConfig)
|
|
75
|
+
tmux: TmuxConfig = Field(default_factory=TmuxConfig)
|
|
76
|
+
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
77
|
+
ui: UIConfig = Field(default_factory=UIConfig)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_default_config_path() -> Path:
|
|
81
|
+
"""Get the default configuration file path in ~/.config/sshplex/sshplex.yaml"""
|
|
82
|
+
return Path.home() / ".config" / "sshplex" / "sshplex.yaml"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_template_config_path() -> Path:
|
|
86
|
+
"""Get the path to the config template file."""
|
|
87
|
+
# Get the directory where this config.py file is located
|
|
88
|
+
lib_dir = Path(__file__).parent
|
|
89
|
+
# Go up to sshplex directory and find config-template.yaml
|
|
90
|
+
sshplex_dir = lib_dir.parent
|
|
91
|
+
return sshplex_dir / "config-template.yaml"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ensure_config_directory() -> Path:
|
|
95
|
+
"""Ensure the ~/.config/sshplex directory exists."""
|
|
96
|
+
config_dir = Path.home() / ".config" / "sshplex"
|
|
97
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
return config_dir
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def initialize_default_config() -> Path:
|
|
102
|
+
"""Initialize default configuration by copying template to ~/.config/sshplex/sshplex.yaml"""
|
|
103
|
+
from .logger import get_logger
|
|
104
|
+
|
|
105
|
+
logger = get_logger()
|
|
106
|
+
config_path = get_default_config_path()
|
|
107
|
+
template_path = get_template_config_path()
|
|
108
|
+
|
|
109
|
+
# Ensure config directory exists
|
|
110
|
+
ensure_config_directory()
|
|
111
|
+
|
|
112
|
+
if not template_path.exists():
|
|
113
|
+
raise FileNotFoundError(f"SSHplex: Template config file not found: {template_path}")
|
|
114
|
+
|
|
115
|
+
# Copy template to default config location
|
|
116
|
+
shutil.copy2(template_path, config_path)
|
|
117
|
+
logger.info(f"SSHplex: Created default configuration at {config_path}")
|
|
118
|
+
logger.info(f"SSHplex: Please edit {config_path} with your NetBox details")
|
|
119
|
+
|
|
120
|
+
return config_path
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_config(config_path: Optional[str] = None) -> Config:
|
|
124
|
+
"""Load and validate configuration from YAML file.
|
|
125
|
+
|
|
126
|
+
Phase 3: Uses ~/.config/sshplex/sshplex.yaml as default location.
|
|
127
|
+
Creates config directory and copies template on first run.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
config_path: Path to configuration file (optional, defaults to ~/.config/sshplex/sshplex.yaml)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Validated configuration object
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
FileNotFoundError: If config file doesn't exist and template can't be found
|
|
137
|
+
ValueError: If config validation fails
|
|
138
|
+
"""
|
|
139
|
+
from .logger import get_logger
|
|
140
|
+
|
|
141
|
+
# Use default config path if none provided
|
|
142
|
+
if config_path is None:
|
|
143
|
+
config_file = get_default_config_path()
|
|
144
|
+
|
|
145
|
+
# If default config doesn't exist, initialize it from template
|
|
146
|
+
if not config_file.exists():
|
|
147
|
+
try:
|
|
148
|
+
config_file = initialize_default_config()
|
|
149
|
+
print(f"ā
SSHplex: First run detected - created configuration at {config_file}")
|
|
150
|
+
print(f"š Please edit {config_file} with your NetBox details before running SSHplex again")
|
|
151
|
+
print(f"š§ Key settings to configure:")
|
|
152
|
+
print(f" - netbox.url: Your NetBox instance URL")
|
|
153
|
+
print(f" - netbox.token: Your NetBox API token")
|
|
154
|
+
print(f" - ssh.username: Your SSH username")
|
|
155
|
+
print(f" - ssh.key_path: Path to your SSH private key")
|
|
156
|
+
print(f"\nš Run 'sshplex' again after configuration is complete!")
|
|
157
|
+
# Exit gracefully to let user configure
|
|
158
|
+
import sys
|
|
159
|
+
sys.exit(0)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
raise FileNotFoundError(f"SSHplex: Could not initialize default config: {e}")
|
|
162
|
+
else:
|
|
163
|
+
config_file = Path(config_path)
|
|
164
|
+
|
|
165
|
+
if not config_file.exists():
|
|
166
|
+
raise FileNotFoundError(f"SSHplex: Configuration file not found: {config_file}")
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
logger = get_logger()
|
|
170
|
+
logger.info(f"SSHplex: Loading configuration from {config_file}")
|
|
171
|
+
|
|
172
|
+
with open(config_file, 'r') as f:
|
|
173
|
+
config_data = yaml.safe_load(f)
|
|
174
|
+
|
|
175
|
+
config = Config(**config_data)
|
|
176
|
+
logger.info("SSHplex: Configuration loaded and validated successfully")
|
|
177
|
+
return config
|
|
178
|
+
|
|
179
|
+
except yaml.YAMLError as e:
|
|
180
|
+
raise ValueError(f"SSHplex: Invalid YAML in config file: {e}")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
raise ValueError(f"SSHplex: Configuration validation failed: {e}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_config_info() -> Dict[str, Any]:
|
|
186
|
+
"""Get information about SSHplex configuration paths and status."""
|
|
187
|
+
default_path = get_default_config_path()
|
|
188
|
+
template_path = get_template_config_path()
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
"default_config_path": str(default_path),
|
|
192
|
+
"default_config_exists": default_path.exists(),
|
|
193
|
+
"template_path": str(template_path),
|
|
194
|
+
"template_exists": template_path.exists(),
|
|
195
|
+
"config_dir_exists": default_path.parent.exists()
|
|
196
|
+
}
|
sshplex/lib/logger.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""SSHplex logging configuration using loguru."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_logging(log_level: str = "INFO", log_file: str = "logs/sshplex.log", enabled: bool = True) -> None:
|
|
10
|
+
"""Set up logging for SSHplex with file rotation.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
|
14
|
+
log_file: Path to log file
|
|
15
|
+
enabled: Whether logging is enabled (if False, only console errors will be shown)
|
|
16
|
+
"""
|
|
17
|
+
# Remove default logger
|
|
18
|
+
logger.remove()
|
|
19
|
+
|
|
20
|
+
if not enabled:
|
|
21
|
+
# When logging is disabled, only show ERROR and CRITICAL messages to console
|
|
22
|
+
logger.add(
|
|
23
|
+
sink=lambda msg: print(msg, end=""),
|
|
24
|
+
level="ERROR",
|
|
25
|
+
format="<red>ERROR</red> | <cyan>SSHplex</cyan> | {message}"
|
|
26
|
+
)
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
# Create logs directory if it doesn't exist
|
|
30
|
+
log_path = Path(log_file)
|
|
31
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
# Add console logging
|
|
34
|
+
logger.add(
|
|
35
|
+
sink=lambda msg: print(msg, end=""),
|
|
36
|
+
level=log_level,
|
|
37
|
+
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>SSHplex</cyan> | {message}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Add file logging with rotation
|
|
41
|
+
logger.add(
|
|
42
|
+
log_file,
|
|
43
|
+
rotation="10 MB",
|
|
44
|
+
retention="30 days",
|
|
45
|
+
level=log_level,
|
|
46
|
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{line} | SSHplex | {message}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logger.info(f"SSHplex logging initialized - Level: {log_level}, File: {log_file}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_logger() -> Any:
|
|
53
|
+
"""Get the configured logger instance."""
|
|
54
|
+
return logger
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# The logs directory is intentionally left blank.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Base class for terminal multiplexers in SSHplex."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MultiplexerBase(ABC):
|
|
8
|
+
"""Abstract base class for terminal multiplexers."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, session_name: str):
|
|
11
|
+
"""Initialize the multiplexer with a session name."""
|
|
12
|
+
self.session_name = session_name
|
|
13
|
+
self.panes: Dict[str, Any] = {}
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def create_session(self) -> bool:
|
|
17
|
+
"""Create a new multiplexer session."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def create_pane(self, hostname: str, command: Optional[str] = None) -> bool:
|
|
22
|
+
"""Create a new pane for the given hostname."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def set_pane_title(self, pane_id: str, title: str) -> bool:
|
|
27
|
+
"""Set the title of a specific pane."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def send_command(self, pane_id: str, command: str) -> bool:
|
|
32
|
+
"""Send a command to a specific pane."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def broadcast_command(self, command: str) -> bool:
|
|
37
|
+
"""Send a command to all panes."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def close_session(self) -> None:
|
|
42
|
+
"""Close the multiplexer session."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def attach_to_session(self) -> None:
|
|
47
|
+
"""Attach to the multiplexer session."""
|
|
48
|
+
pass
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""SSHplex tmux multiplexer implementation."""
|
|
2
|
+
|
|
3
|
+
import libtmux
|
|
4
|
+
from typing import Optional, Dict
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from .base import MultiplexerBase
|
|
8
|
+
from ..logger import get_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TmuxManager(MultiplexerBase):
|
|
12
|
+
"""tmux implementation for SSHplex multiplexer."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, session_name: Optional[str] = None):
|
|
15
|
+
"""Initialize tmux manager with session name."""
|
|
16
|
+
if session_name is None:
|
|
17
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
18
|
+
session_name = f"sshplex-{timestamp}"
|
|
19
|
+
|
|
20
|
+
super().__init__(session_name)
|
|
21
|
+
self.logger = get_logger()
|
|
22
|
+
self.server = libtmux.Server()
|
|
23
|
+
self.session: Optional[libtmux.Session] = None
|
|
24
|
+
self.window: Optional[libtmux.Window] = None
|
|
25
|
+
self.panes: Dict[str, libtmux.Pane] = {}
|
|
26
|
+
|
|
27
|
+
def create_session(self) -> bool:
|
|
28
|
+
"""Create a new tmux session with SSHplex branding."""
|
|
29
|
+
try:
|
|
30
|
+
self.logger.info(f"SSHplex: Creating tmux session '{self.session_name}'")
|
|
31
|
+
|
|
32
|
+
# Check if session already exists
|
|
33
|
+
if self.server.has_session(self.session_name):
|
|
34
|
+
self.logger.warning(f"SSHplex: Session '{self.session_name}' already exists")
|
|
35
|
+
self.session = self.server.sessions.get(session_name=self.session_name)
|
|
36
|
+
else:
|
|
37
|
+
# Create new session
|
|
38
|
+
self.session = self.server.new_session(
|
|
39
|
+
session_name=self.session_name,
|
|
40
|
+
window_name="sshplex",
|
|
41
|
+
start_directory="~"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Get the main window
|
|
45
|
+
if self.session:
|
|
46
|
+
self.window = self.session.attached_window
|
|
47
|
+
self.logger.info(f"SSHplex: tmux session '{self.session_name}' created successfully")
|
|
48
|
+
return True
|
|
49
|
+
else:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
self.logger.error(f"SSHplex: Failed to create tmux session: {e}")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def create_pane(self, hostname: str, command: Optional[str] = None) -> bool:
|
|
57
|
+
"""Create a new pane for the given hostname."""
|
|
58
|
+
try:
|
|
59
|
+
if self.session is None or self.window is None:
|
|
60
|
+
if not self.create_session():
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
self.logger.info(f"SSHplex: Creating pane for host '{hostname}'")
|
|
64
|
+
|
|
65
|
+
# Split window to create new pane (except for the first pane)
|
|
66
|
+
if self.window is None:
|
|
67
|
+
self.logger.error("SSHplex: No window available for pane creation")
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
if len(self.panes) == 0:
|
|
71
|
+
# First pane - use the existing window pane
|
|
72
|
+
pane = self.window.attached_pane
|
|
73
|
+
if pane is None:
|
|
74
|
+
raise RuntimeError(f"No attached pane available for {hostname}")
|
|
75
|
+
else:
|
|
76
|
+
# Additional panes - split the window
|
|
77
|
+
pane = self.window.split_window()
|
|
78
|
+
if pane is None:
|
|
79
|
+
raise RuntimeError(f"Failed to create tmux pane for {hostname}")
|
|
80
|
+
|
|
81
|
+
# Store pane reference
|
|
82
|
+
self.panes[hostname] = pane
|
|
83
|
+
|
|
84
|
+
# Set pane title
|
|
85
|
+
self.set_pane_title(hostname, hostname)
|
|
86
|
+
|
|
87
|
+
# Execute the provided command (should be SSH command)
|
|
88
|
+
if command:
|
|
89
|
+
self.send_command(hostname, command)
|
|
90
|
+
|
|
91
|
+
self.logger.info(f"SSHplex: Pane created for '{hostname}' successfully")
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
except Exception as e:
|
|
95
|
+
self.logger.error(f"SSHplex: Failed to create pane for '{hostname}': {e}")
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def create_window(self, hostname: str, command: Optional[str] = None) -> bool:
|
|
99
|
+
"""Create a new window (tab) in the tmux session and execute a command."""
|
|
100
|
+
try:
|
|
101
|
+
if not self.session:
|
|
102
|
+
self.logger.error("SSHplex: No active tmux session for window creation")
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
# Create new window with hostname as the window name
|
|
106
|
+
window = self.session.new_window(window_name=hostname)
|
|
107
|
+
|
|
108
|
+
if not window:
|
|
109
|
+
self.logger.error(f"SSHplex: Failed to create window for '{hostname}'")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
# Get the main pane of the new window
|
|
113
|
+
pane = window.panes[0] if window.panes else None
|
|
114
|
+
if not pane:
|
|
115
|
+
self.logger.error(f"SSHplex: No pane found in new window for '{hostname}'")
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# Store the pane reference
|
|
119
|
+
self.panes[hostname] = pane
|
|
120
|
+
|
|
121
|
+
# Execute the provided command (should be SSH command)
|
|
122
|
+
if command:
|
|
123
|
+
pane.send_keys(command, enter=True)
|
|
124
|
+
|
|
125
|
+
self.logger.info(f"SSHplex: Window created for '{hostname}' successfully")
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
self.logger.error(f"SSHplex: Failed to create window for '{hostname}': {e}")
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def set_pane_title(self, hostname: str, title: str) -> bool:
|
|
133
|
+
"""Set the title of a specific pane."""
|
|
134
|
+
try:
|
|
135
|
+
if hostname not in self.panes:
|
|
136
|
+
self.logger.error(f"SSHplex: Pane for '{hostname}' not found")
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
pane = self.panes[hostname]
|
|
140
|
+
# Set pane title using printf escape sequence
|
|
141
|
+
pane.send_keys(f'printf "\\033]2;{title}\\033\\\\"', enter=True)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
self.logger.error(f"SSHplex: Failed to set pane title for '{hostname}': {e}")
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def send_command(self, hostname: str, command: str) -> bool:
|
|
149
|
+
"""Send a command to a specific pane."""
|
|
150
|
+
try:
|
|
151
|
+
if hostname not in self.panes:
|
|
152
|
+
self.logger.error(f"SSHplex: Pane for '{hostname}' not found")
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
pane = self.panes[hostname]
|
|
156
|
+
pane.send_keys(command, enter=True)
|
|
157
|
+
self.logger.debug(f"SSHplex: Command sent to '{hostname}': {command}")
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
self.logger.error(f"SSHplex: Failed to send command to '{hostname}': {e}")
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
def broadcast_command(self, command: str) -> bool:
|
|
165
|
+
"""Send a command to all panes."""
|
|
166
|
+
try:
|
|
167
|
+
success_count = 0
|
|
168
|
+
for hostname in self.panes:
|
|
169
|
+
if self.send_command(hostname, command):
|
|
170
|
+
success_count += 1
|
|
171
|
+
|
|
172
|
+
self.logger.info(f"SSHplex: Broadcast command sent to {success_count}/{len(self.panes)} panes")
|
|
173
|
+
return success_count == len(self.panes)
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
self.logger.error(f"SSHplex: Failed to broadcast command: {e}")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
def close_session(self) -> None:
|
|
180
|
+
"""Close the tmux session."""
|
|
181
|
+
try:
|
|
182
|
+
if self.session:
|
|
183
|
+
self.logger.info(f"SSHplex: Closing tmux session '{self.session_name}'")
|
|
184
|
+
self.session.kill_session()
|
|
185
|
+
self.session = None
|
|
186
|
+
self.window = None
|
|
187
|
+
self.panes.clear()
|
|
188
|
+
|
|
189
|
+
except Exception as e:
|
|
190
|
+
self.logger.error(f"SSHplex: Error closing session: {e}")
|
|
191
|
+
|
|
192
|
+
def attach_to_session(self, auto_attach: bool = True) -> None:
|
|
193
|
+
"""Attach to the tmux session."""
|
|
194
|
+
try:
|
|
195
|
+
if self.session:
|
|
196
|
+
if auto_attach:
|
|
197
|
+
self.logger.info(f"SSHplex: Auto-attaching to tmux session '{self.session_name}'")
|
|
198
|
+
# Auto-attach to the session by replacing current shell
|
|
199
|
+
import os
|
|
200
|
+
import sys
|
|
201
|
+
# Use exec to replace the current Python process with tmux attach
|
|
202
|
+
os.execlp("tmux", "tmux", "attach-session", "-t", self.session_name)
|
|
203
|
+
else:
|
|
204
|
+
self.logger.info(f"SSHplex: Tmux session '{self.session_name}' is ready for attachment")
|
|
205
|
+
print(f"\nTo attach to the session, run: tmux attach-session -t {self.session_name}")
|
|
206
|
+
else:
|
|
207
|
+
self.logger.error("SSHplex: No session to attach to")
|
|
208
|
+
|
|
209
|
+
except Exception as e:
|
|
210
|
+
self.logger.error(f"SSHplex: Error attaching to session: {e}")
|
|
211
|
+
|
|
212
|
+
def get_session_name(self) -> str:
|
|
213
|
+
"""Get the tmux session name for external attachment."""
|
|
214
|
+
return self.session_name
|
|
215
|
+
|
|
216
|
+
def setup_tiled_layout(self) -> bool:
|
|
217
|
+
"""Set up tiled layout for multiple panes."""
|
|
218
|
+
try:
|
|
219
|
+
if self.window and len(self.panes) > 1:
|
|
220
|
+
self.window.select_layout('tiled')
|
|
221
|
+
self.logger.info("SSHplex: Applied tiled layout to tmux window")
|
|
222
|
+
return True
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
self.logger.error(f"SSHplex: Failed to set tiled layout: {e}")
|
|
227
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# The logs directory is intentionally left blank.
|