mcp-ssh-vps 0.4.1__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.
- mcp_ssh_vps-0.4.1.dist-info/METADATA +482 -0
- mcp_ssh_vps-0.4.1.dist-info/RECORD +47 -0
- mcp_ssh_vps-0.4.1.dist-info/WHEEL +5 -0
- mcp_ssh_vps-0.4.1.dist-info/entry_points.txt +4 -0
- mcp_ssh_vps-0.4.1.dist-info/licenses/LICENSE +21 -0
- mcp_ssh_vps-0.4.1.dist-info/top_level.txt +1 -0
- sshmcp/__init__.py +3 -0
- sshmcp/cli.py +473 -0
- sshmcp/config.py +155 -0
- sshmcp/core/__init__.py +5 -0
- sshmcp/core/container.py +291 -0
- sshmcp/models/__init__.py +15 -0
- sshmcp/models/command.py +69 -0
- sshmcp/models/file.py +102 -0
- sshmcp/models/machine.py +139 -0
- sshmcp/monitoring/__init__.py +0 -0
- sshmcp/monitoring/alerts.py +464 -0
- sshmcp/prompts/__init__.py +7 -0
- sshmcp/prompts/backup.py +151 -0
- sshmcp/prompts/deploy.py +115 -0
- sshmcp/prompts/monitor.py +146 -0
- sshmcp/resources/__init__.py +7 -0
- sshmcp/resources/logs.py +99 -0
- sshmcp/resources/metrics.py +204 -0
- sshmcp/resources/status.py +160 -0
- sshmcp/security/__init__.py +7 -0
- sshmcp/security/audit.py +314 -0
- sshmcp/security/rate_limiter.py +221 -0
- sshmcp/security/totp.py +392 -0
- sshmcp/security/validator.py +234 -0
- sshmcp/security/whitelist.py +169 -0
- sshmcp/server.py +632 -0
- sshmcp/ssh/__init__.py +6 -0
- sshmcp/ssh/async_client.py +247 -0
- sshmcp/ssh/client.py +464 -0
- sshmcp/ssh/executor.py +79 -0
- sshmcp/ssh/forwarding.py +368 -0
- sshmcp/ssh/pool.py +343 -0
- sshmcp/ssh/shell.py +518 -0
- sshmcp/ssh/transfer.py +461 -0
- sshmcp/tools/__init__.py +13 -0
- sshmcp/tools/commands.py +226 -0
- sshmcp/tools/files.py +220 -0
- sshmcp/tools/helpers.py +321 -0
- sshmcp/tools/history.py +372 -0
- sshmcp/tools/processes.py +214 -0
- sshmcp/tools/servers.py +484 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Async SSH client wrapper for parallel execution."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
from sshmcp.models.command import CommandResult
|
|
9
|
+
from sshmcp.models.file import FileContent, FileInfo, FileUploadResult
|
|
10
|
+
from sshmcp.models.machine import MachineConfig
|
|
11
|
+
from sshmcp.ssh.client import SSHClient, SSHConnectionError, SSHExecutionError
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncSSHClient:
|
|
17
|
+
"""
|
|
18
|
+
Async wrapper around SSHClient for non-blocking operations.
|
|
19
|
+
|
|
20
|
+
Uses asyncio.to_thread() to run blocking paramiko operations
|
|
21
|
+
in a thread pool without blocking the event loop.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, machine: MachineConfig) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Initialize async SSH client.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
machine: Machine configuration.
|
|
30
|
+
"""
|
|
31
|
+
self.machine = machine
|
|
32
|
+
self._sync_client: SSHClient | None = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_connected(self) -> bool:
|
|
36
|
+
"""Check if SSH connection is active."""
|
|
37
|
+
return self._sync_client is not None and self._sync_client.is_connected
|
|
38
|
+
|
|
39
|
+
async def connect(self, retry: bool = True) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Establish SSH connection asynchronously.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
retry: Whether to retry on failure.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
SSHConnectionError: If connection fails.
|
|
48
|
+
"""
|
|
49
|
+
if self.is_connected:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
self._sync_client = SSHClient(self.machine)
|
|
53
|
+
await asyncio.to_thread(self._sync_client.connect, retry)
|
|
54
|
+
logger.info("async_ssh_connected", host=self.machine.host)
|
|
55
|
+
|
|
56
|
+
async def disconnect(self) -> None:
|
|
57
|
+
"""Close SSH connection asynchronously."""
|
|
58
|
+
if self._sync_client:
|
|
59
|
+
await asyncio.to_thread(self._sync_client.disconnect)
|
|
60
|
+
self._sync_client = None
|
|
61
|
+
logger.info("async_ssh_disconnected", host=self.machine.host)
|
|
62
|
+
|
|
63
|
+
async def execute(self, command: str, timeout: int | None = None) -> CommandResult:
|
|
64
|
+
"""
|
|
65
|
+
Execute command asynchronously.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
command: Command to execute.
|
|
69
|
+
timeout: Optional timeout in seconds.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
CommandResult with execution details.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
SSHExecutionError: If execution fails.
|
|
76
|
+
"""
|
|
77
|
+
if not self.is_connected:
|
|
78
|
+
await self.connect()
|
|
79
|
+
|
|
80
|
+
return await asyncio.to_thread(
|
|
81
|
+
self._sync_client.execute,
|
|
82
|
+
command,
|
|
83
|
+
timeout, # type: ignore
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def read_file(
|
|
87
|
+
self,
|
|
88
|
+
path: str,
|
|
89
|
+
encoding: str = "utf-8",
|
|
90
|
+
max_size: int = 1024 * 1024,
|
|
91
|
+
) -> FileContent:
|
|
92
|
+
"""
|
|
93
|
+
Read file asynchronously.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
path: Path to file.
|
|
97
|
+
encoding: File encoding.
|
|
98
|
+
max_size: Maximum file size to read.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
FileContent with file data.
|
|
102
|
+
"""
|
|
103
|
+
if not self.is_connected:
|
|
104
|
+
await self.connect()
|
|
105
|
+
|
|
106
|
+
return await asyncio.to_thread(
|
|
107
|
+
self._sync_client.read_file,
|
|
108
|
+
path,
|
|
109
|
+
encoding,
|
|
110
|
+
max_size, # type: ignore
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def write_file(
|
|
114
|
+
self,
|
|
115
|
+
path: str,
|
|
116
|
+
content: str,
|
|
117
|
+
mode: str | None = None,
|
|
118
|
+
) -> FileUploadResult:
|
|
119
|
+
"""
|
|
120
|
+
Write file asynchronously.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
path: Destination path.
|
|
124
|
+
content: File content.
|
|
125
|
+
mode: Optional file permissions.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
FileUploadResult with upload details.
|
|
129
|
+
"""
|
|
130
|
+
if not self.is_connected:
|
|
131
|
+
await self.connect()
|
|
132
|
+
|
|
133
|
+
return await asyncio.to_thread(
|
|
134
|
+
self._sync_client.write_file,
|
|
135
|
+
path,
|
|
136
|
+
content,
|
|
137
|
+
mode, # type: ignore
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def list_files(
|
|
141
|
+
self,
|
|
142
|
+
directory: str,
|
|
143
|
+
recursive: bool = False,
|
|
144
|
+
) -> list[FileInfo]:
|
|
145
|
+
"""
|
|
146
|
+
List files asynchronously.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
directory: Directory path.
|
|
150
|
+
recursive: Whether to list recursively.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of FileInfo objects.
|
|
154
|
+
"""
|
|
155
|
+
if not self.is_connected:
|
|
156
|
+
await self.connect()
|
|
157
|
+
|
|
158
|
+
return await asyncio.to_thread(
|
|
159
|
+
self._sync_client.list_files,
|
|
160
|
+
directory,
|
|
161
|
+
recursive, # type: ignore
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def __aenter__(self) -> "AsyncSSHClient":
|
|
165
|
+
"""Async context manager entry."""
|
|
166
|
+
await self.connect()
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
170
|
+
"""Async context manager exit."""
|
|
171
|
+
await self.disconnect()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def execute_on_hosts_async(
|
|
175
|
+
machines: list[MachineConfig],
|
|
176
|
+
command: str,
|
|
177
|
+
timeout: int | None = None,
|
|
178
|
+
max_concurrency: int = 10,
|
|
179
|
+
) -> dict[str, dict[str, Any]]:
|
|
180
|
+
"""
|
|
181
|
+
Execute command on multiple hosts concurrently.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
machines: List of machine configurations.
|
|
185
|
+
command: Command to execute.
|
|
186
|
+
timeout: Optional timeout per host.
|
|
187
|
+
max_concurrency: Maximum concurrent connections.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Dictionary mapping host names to results.
|
|
191
|
+
"""
|
|
192
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
193
|
+
results: dict[str, dict[str, Any]] = {}
|
|
194
|
+
|
|
195
|
+
async def run_on_host(machine: MachineConfig) -> tuple[str, dict[str, Any]]:
|
|
196
|
+
async with semaphore:
|
|
197
|
+
try:
|
|
198
|
+
async with AsyncSSHClient(machine) as client:
|
|
199
|
+
result = await client.execute(command, timeout)
|
|
200
|
+
return machine.name, {
|
|
201
|
+
"success": True,
|
|
202
|
+
**result.to_dict(),
|
|
203
|
+
}
|
|
204
|
+
except (SSHConnectionError, SSHExecutionError) as e:
|
|
205
|
+
return machine.name, {
|
|
206
|
+
"success": False,
|
|
207
|
+
"error": str(e),
|
|
208
|
+
}
|
|
209
|
+
except Exception as e:
|
|
210
|
+
return machine.name, {
|
|
211
|
+
"success": False,
|
|
212
|
+
"error": f"Unexpected error: {e}",
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
tasks = [run_on_host(machine) for machine in machines]
|
|
216
|
+
task_results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
217
|
+
|
|
218
|
+
for result in task_results:
|
|
219
|
+
if isinstance(result, Exception):
|
|
220
|
+
logger.error("async_execution_error", error=str(result))
|
|
221
|
+
else:
|
|
222
|
+
host, data = result
|
|
223
|
+
results[host] = data
|
|
224
|
+
|
|
225
|
+
return results
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def health_check_hosts_async(
|
|
229
|
+
machines: list[MachineConfig],
|
|
230
|
+
max_concurrency: int = 20,
|
|
231
|
+
) -> dict[str, dict[str, Any]]:
|
|
232
|
+
"""
|
|
233
|
+
Check health of multiple hosts concurrently.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
machines: List of machine configurations.
|
|
237
|
+
max_concurrency: Maximum concurrent checks.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Dictionary mapping host names to health status.
|
|
241
|
+
"""
|
|
242
|
+
return await execute_on_hosts_async(
|
|
243
|
+
machines,
|
|
244
|
+
"echo ok",
|
|
245
|
+
timeout=5,
|
|
246
|
+
max_concurrency=max_concurrency,
|
|
247
|
+
)
|
sshmcp/ssh/client.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""SSH client for remote server connections."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import paramiko
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from sshmcp.models.command import CommandResult
|
|
11
|
+
from sshmcp.models.file import FileContent, FileInfo, FileUploadResult
|
|
12
|
+
from sshmcp.models.machine import MachineConfig
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SSHConnectionError(Exception):
|
|
18
|
+
"""Error connecting to SSH server."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SSHExecutionError(Exception):
|
|
24
|
+
"""Error executing command on SSH server."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SSHClient:
|
|
30
|
+
"""SSH client wrapper around paramiko."""
|
|
31
|
+
|
|
32
|
+
# Retry configuration
|
|
33
|
+
MAX_RETRIES = 3
|
|
34
|
+
RETRY_DELAY = 1.0 # seconds
|
|
35
|
+
RETRY_BACKOFF = 2.0 # exponential backoff multiplier
|
|
36
|
+
|
|
37
|
+
def __init__(self, machine: MachineConfig) -> None:
|
|
38
|
+
"""
|
|
39
|
+
Initialize SSH client for a machine.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
machine: Machine configuration.
|
|
43
|
+
"""
|
|
44
|
+
self.machine = machine
|
|
45
|
+
self._client: paramiko.SSHClient | None = None
|
|
46
|
+
self._sftp: paramiko.SFTPClient | None = None
|
|
47
|
+
self._retry_count = 0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_connected(self) -> bool:
|
|
51
|
+
"""Check if SSH connection is active."""
|
|
52
|
+
if self._client is None:
|
|
53
|
+
return False
|
|
54
|
+
transport = self._client.get_transport()
|
|
55
|
+
return transport is not None and transport.is_active()
|
|
56
|
+
|
|
57
|
+
def connect(self, retry: bool = True) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Establish SSH connection to the server with retry logic.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
retry: Whether to retry on failure (default: True).
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
SSHConnectionError: If connection fails after all retries.
|
|
66
|
+
"""
|
|
67
|
+
if self.is_connected:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
last_error: Exception | None = None
|
|
71
|
+
max_attempts = self.MAX_RETRIES if retry else 1
|
|
72
|
+
|
|
73
|
+
for attempt in range(max_attempts):
|
|
74
|
+
try:
|
|
75
|
+
self._connect_once()
|
|
76
|
+
self._retry_count = 0 # Reset on success
|
|
77
|
+
return
|
|
78
|
+
except SSHConnectionError as e:
|
|
79
|
+
last_error = e
|
|
80
|
+
if attempt < max_attempts - 1:
|
|
81
|
+
delay = self.RETRY_DELAY * (self.RETRY_BACKOFF**attempt)
|
|
82
|
+
logger.warning(
|
|
83
|
+
"ssh_connection_retry",
|
|
84
|
+
host=self.machine.host,
|
|
85
|
+
attempt=attempt + 1,
|
|
86
|
+
max_attempts=max_attempts,
|
|
87
|
+
delay=delay,
|
|
88
|
+
error=str(e),
|
|
89
|
+
)
|
|
90
|
+
time.sleep(delay)
|
|
91
|
+
|
|
92
|
+
self._retry_count += 1
|
|
93
|
+
raise SSHConnectionError(
|
|
94
|
+
f"Failed to connect after {max_attempts} attempts: {last_error}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _connect_once(self) -> None:
|
|
98
|
+
"""Single connection attempt without retry."""
|
|
99
|
+
logger.info(
|
|
100
|
+
"ssh_connecting",
|
|
101
|
+
host=self.machine.host,
|
|
102
|
+
port=self.machine.port,
|
|
103
|
+
user=self.machine.user,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self._client = paramiko.SSHClient()
|
|
107
|
+
self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
connect_kwargs: dict[str, Any] = {
|
|
111
|
+
"hostname": self.machine.host,
|
|
112
|
+
"port": self.machine.port,
|
|
113
|
+
"username": self.machine.user,
|
|
114
|
+
"timeout": 30,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
auth = self.machine.auth
|
|
118
|
+
if auth.type == "key":
|
|
119
|
+
key_path = Path(auth.key_path).expanduser() # type: ignore
|
|
120
|
+
if not key_path.exists():
|
|
121
|
+
raise SSHConnectionError(f"SSH key not found: {key_path}")
|
|
122
|
+
|
|
123
|
+
# Try to load the key
|
|
124
|
+
try:
|
|
125
|
+
if auth.passphrase:
|
|
126
|
+
pkey = paramiko.RSAKey.from_private_key_file(
|
|
127
|
+
str(key_path), password=auth.passphrase
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
# Try RSA first, then Ed25519, then ECDSA
|
|
131
|
+
pkey = self._load_private_key(key_path, auth.passphrase)
|
|
132
|
+
connect_kwargs["pkey"] = pkey
|
|
133
|
+
except Exception as e:
|
|
134
|
+
raise SSHConnectionError(f"Failed to load SSH key: {e}")
|
|
135
|
+
|
|
136
|
+
elif auth.type == "password":
|
|
137
|
+
connect_kwargs["password"] = auth.password
|
|
138
|
+
|
|
139
|
+
elif auth.type == "agent":
|
|
140
|
+
# Use SSH agent for authentication
|
|
141
|
+
connect_kwargs["allow_agent"] = True
|
|
142
|
+
connect_kwargs["look_for_keys"] = False
|
|
143
|
+
|
|
144
|
+
# Enable agent forwarding if requested
|
|
145
|
+
if auth.agent_forwarding:
|
|
146
|
+
connect_kwargs["allow_agent"] = True
|
|
147
|
+
|
|
148
|
+
self._client.connect(**connect_kwargs)
|
|
149
|
+
logger.info("ssh_connected", host=self.machine.host)
|
|
150
|
+
|
|
151
|
+
except paramiko.AuthenticationException as e:
|
|
152
|
+
raise SSHConnectionError(f"Authentication failed: {e}")
|
|
153
|
+
except paramiko.SSHException as e:
|
|
154
|
+
raise SSHConnectionError(f"SSH error: {e}")
|
|
155
|
+
except Exception as e:
|
|
156
|
+
raise SSHConnectionError(f"Connection failed: {e}")
|
|
157
|
+
|
|
158
|
+
def _load_private_key(
|
|
159
|
+
self, key_path: Path, passphrase: str | None
|
|
160
|
+
) -> paramiko.PKey:
|
|
161
|
+
"""Try to load private key with different algorithms."""
|
|
162
|
+
key_types = [
|
|
163
|
+
paramiko.RSAKey,
|
|
164
|
+
paramiko.Ed25519Key,
|
|
165
|
+
paramiko.ECDSAKey,
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
last_error = None
|
|
169
|
+
for key_type in key_types:
|
|
170
|
+
try:
|
|
171
|
+
return key_type.from_private_key_file(
|
|
172
|
+
str(key_path), password=passphrase
|
|
173
|
+
)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
last_error = e
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
raise SSHConnectionError(
|
|
179
|
+
f"Could not load SSH key with any supported algorithm: {last_error}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def disconnect(self) -> None:
|
|
183
|
+
"""Close SSH connection."""
|
|
184
|
+
if self._sftp:
|
|
185
|
+
try:
|
|
186
|
+
self._sftp.close()
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
self._sftp = None
|
|
190
|
+
|
|
191
|
+
if self._client:
|
|
192
|
+
try:
|
|
193
|
+
self._client.close()
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
self._client = None
|
|
197
|
+
|
|
198
|
+
logger.info("ssh_disconnected", host=self.machine.host)
|
|
199
|
+
|
|
200
|
+
def execute(self, command: str, timeout: int | None = None) -> CommandResult:
|
|
201
|
+
"""
|
|
202
|
+
Execute command on remote server.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
command: Command to execute.
|
|
206
|
+
timeout: Optional timeout in seconds. Uses machine config if not provided.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
CommandResult with execution details.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
SSHExecutionError: If execution fails.
|
|
213
|
+
"""
|
|
214
|
+
if not self.is_connected:
|
|
215
|
+
self.connect()
|
|
216
|
+
|
|
217
|
+
if timeout is None:
|
|
218
|
+
timeout = self.machine.security.timeout_seconds
|
|
219
|
+
|
|
220
|
+
logger.info(
|
|
221
|
+
"ssh_executing",
|
|
222
|
+
host=self.machine.host,
|
|
223
|
+
command=command,
|
|
224
|
+
timeout=timeout,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
start_time = time.time()
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
stdin, stdout, stderr = self._client.exec_command( # type: ignore
|
|
231
|
+
command, timeout=timeout
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
235
|
+
stdout_text = stdout.read().decode("utf-8", errors="replace")
|
|
236
|
+
stderr_text = stderr.read().decode("utf-8", errors="replace")
|
|
237
|
+
|
|
238
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
239
|
+
|
|
240
|
+
result = CommandResult(
|
|
241
|
+
exit_code=exit_code,
|
|
242
|
+
stdout=stdout_text,
|
|
243
|
+
stderr=stderr_text,
|
|
244
|
+
duration_ms=duration_ms,
|
|
245
|
+
host=self.machine.name,
|
|
246
|
+
command=command,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
logger.info(
|
|
250
|
+
"ssh_executed",
|
|
251
|
+
host=self.machine.host,
|
|
252
|
+
exit_code=exit_code,
|
|
253
|
+
duration_ms=duration_ms,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
except paramiko.SSHException as e:
|
|
259
|
+
raise SSHExecutionError(f"SSH execution error: {e}")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
raise SSHExecutionError(f"Command execution failed: {e}")
|
|
262
|
+
|
|
263
|
+
def _get_sftp(self) -> paramiko.SFTPClient:
|
|
264
|
+
"""Get or create SFTP client."""
|
|
265
|
+
if not self.is_connected:
|
|
266
|
+
self.connect()
|
|
267
|
+
|
|
268
|
+
if self._sftp is None:
|
|
269
|
+
self._sftp = self._client.open_sftp() # type: ignore
|
|
270
|
+
|
|
271
|
+
return self._sftp
|
|
272
|
+
|
|
273
|
+
def read_file(
|
|
274
|
+
self,
|
|
275
|
+
path: str,
|
|
276
|
+
encoding: str = "utf-8",
|
|
277
|
+
max_size: int = 1024 * 1024, # 1MB default
|
|
278
|
+
) -> FileContent:
|
|
279
|
+
"""
|
|
280
|
+
Read file content from remote server.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
path: Path to file on remote server.
|
|
284
|
+
encoding: File encoding.
|
|
285
|
+
max_size: Maximum file size to read in bytes.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
FileContent with file data.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
SSHExecutionError: If file cannot be read.
|
|
292
|
+
"""
|
|
293
|
+
sftp = self._get_sftp()
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
stat = sftp.stat(path)
|
|
297
|
+
file_size = stat.st_size or 0
|
|
298
|
+
|
|
299
|
+
truncated = file_size > max_size
|
|
300
|
+
read_size = min(file_size, max_size)
|
|
301
|
+
|
|
302
|
+
with sftp.open(path, "r") as f:
|
|
303
|
+
content = f.read(read_size)
|
|
304
|
+
if isinstance(content, bytes):
|
|
305
|
+
content = content.decode(encoding, errors="replace")
|
|
306
|
+
|
|
307
|
+
logger.info(
|
|
308
|
+
"ssh_file_read",
|
|
309
|
+
host=self.machine.host,
|
|
310
|
+
path=path,
|
|
311
|
+
size=file_size,
|
|
312
|
+
truncated=truncated,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return FileContent(
|
|
316
|
+
content=content,
|
|
317
|
+
path=path,
|
|
318
|
+
size=file_size,
|
|
319
|
+
encoding=encoding,
|
|
320
|
+
truncated=truncated,
|
|
321
|
+
host=self.machine.name,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
except FileNotFoundError:
|
|
325
|
+
raise SSHExecutionError(f"File not found: {path}")
|
|
326
|
+
except PermissionError:
|
|
327
|
+
raise SSHExecutionError(f"Permission denied: {path}")
|
|
328
|
+
except Exception as e:
|
|
329
|
+
raise SSHExecutionError(f"Error reading file: {e}")
|
|
330
|
+
|
|
331
|
+
def write_file(
|
|
332
|
+
self,
|
|
333
|
+
path: str,
|
|
334
|
+
content: str,
|
|
335
|
+
mode: str | None = None,
|
|
336
|
+
) -> FileUploadResult:
|
|
337
|
+
"""
|
|
338
|
+
Write file to remote server.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
path: Destination path on remote server.
|
|
342
|
+
content: File content to write.
|
|
343
|
+
mode: Optional file permissions (e.g., "0644").
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
FileUploadResult with upload details.
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
SSHExecutionError: If file cannot be written.
|
|
350
|
+
"""
|
|
351
|
+
sftp = self._get_sftp()
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
content_bytes = content.encode("utf-8")
|
|
355
|
+
size = len(content_bytes)
|
|
356
|
+
|
|
357
|
+
with sftp.open(path, "w") as f:
|
|
358
|
+
f.write(content_bytes)
|
|
359
|
+
|
|
360
|
+
if mode:
|
|
361
|
+
mode_int = int(mode, 8)
|
|
362
|
+
sftp.chmod(path, mode_int)
|
|
363
|
+
|
|
364
|
+
logger.info(
|
|
365
|
+
"ssh_file_written",
|
|
366
|
+
host=self.machine.host,
|
|
367
|
+
path=path,
|
|
368
|
+
size=size,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
return FileUploadResult(
|
|
372
|
+
success=True,
|
|
373
|
+
path=path,
|
|
374
|
+
size=size,
|
|
375
|
+
host=self.machine.name,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
except PermissionError:
|
|
379
|
+
raise SSHExecutionError(f"Permission denied: {path}")
|
|
380
|
+
except Exception as e:
|
|
381
|
+
raise SSHExecutionError(f"Error writing file: {e}")
|
|
382
|
+
|
|
383
|
+
def list_files(
|
|
384
|
+
self,
|
|
385
|
+
directory: str,
|
|
386
|
+
recursive: bool = False,
|
|
387
|
+
) -> list[FileInfo]:
|
|
388
|
+
"""
|
|
389
|
+
List files in remote directory.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
directory: Directory path on remote server.
|
|
393
|
+
recursive: Whether to list files recursively.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
List of FileInfo objects.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
SSHExecutionError: If directory cannot be listed.
|
|
400
|
+
"""
|
|
401
|
+
sftp = self._get_sftp()
|
|
402
|
+
files: list[FileInfo] = []
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
self._list_directory(sftp, directory, files, recursive)
|
|
406
|
+
return files
|
|
407
|
+
|
|
408
|
+
except FileNotFoundError:
|
|
409
|
+
raise SSHExecutionError(f"Directory not found: {directory}")
|
|
410
|
+
except PermissionError:
|
|
411
|
+
raise SSHExecutionError(f"Permission denied: {directory}")
|
|
412
|
+
except Exception as e:
|
|
413
|
+
raise SSHExecutionError(f"Error listing directory: {e}")
|
|
414
|
+
|
|
415
|
+
def _list_directory(
|
|
416
|
+
self,
|
|
417
|
+
sftp: paramiko.SFTPClient,
|
|
418
|
+
directory: str,
|
|
419
|
+
files: list[FileInfo],
|
|
420
|
+
recursive: bool,
|
|
421
|
+
) -> None:
|
|
422
|
+
"""Recursively list directory contents."""
|
|
423
|
+
import stat
|
|
424
|
+
from datetime import datetime
|
|
425
|
+
|
|
426
|
+
for entry in sftp.listdir_attr(directory):
|
|
427
|
+
full_path = f"{directory.rstrip('/')}/{entry.filename}"
|
|
428
|
+
|
|
429
|
+
# Determine file type
|
|
430
|
+
if stat.S_ISDIR(entry.st_mode or 0):
|
|
431
|
+
file_type = "directory"
|
|
432
|
+
elif stat.S_ISLNK(entry.st_mode or 0):
|
|
433
|
+
file_type = "link"
|
|
434
|
+
elif stat.S_ISREG(entry.st_mode or 0):
|
|
435
|
+
file_type = "file"
|
|
436
|
+
else:
|
|
437
|
+
file_type = "other"
|
|
438
|
+
|
|
439
|
+
file_info = FileInfo(
|
|
440
|
+
name=entry.filename,
|
|
441
|
+
path=full_path,
|
|
442
|
+
type=file_type, # type: ignore
|
|
443
|
+
size=entry.st_size or 0,
|
|
444
|
+
modified=datetime.fromtimestamp(entry.st_mtime or 0),
|
|
445
|
+
permissions=oct(entry.st_mode or 0)[-4:] if entry.st_mode else None,
|
|
446
|
+
owner=str(entry.st_uid) if entry.st_uid else None,
|
|
447
|
+
group=str(entry.st_gid) if entry.st_gid else None,
|
|
448
|
+
)
|
|
449
|
+
files.append(file_info)
|
|
450
|
+
|
|
451
|
+
if recursive and file_type == "directory":
|
|
452
|
+
try:
|
|
453
|
+
self._list_directory(sftp, full_path, files, recursive)
|
|
454
|
+
except PermissionError:
|
|
455
|
+
pass # Skip directories we can't access
|
|
456
|
+
|
|
457
|
+
def __enter__(self) -> "SSHClient":
|
|
458
|
+
"""Context manager entry."""
|
|
459
|
+
self.connect()
|
|
460
|
+
return self
|
|
461
|
+
|
|
462
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
463
|
+
"""Context manager exit."""
|
|
464
|
+
self.disconnect()
|