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
sshmcp/ssh/pool.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""SSH connection pool for managing multiple connections."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Callable
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from sshmcp.models.machine import MachineConfig
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sshmcp.models.machine import MachinesConfig
|
|
14
|
+
from sshmcp.ssh.client import SSHClient, SSHConnectionError
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SSHConnectionPool:
|
|
20
|
+
"""
|
|
21
|
+
Pool for managing SSH connections to multiple servers.
|
|
22
|
+
|
|
23
|
+
Provides connection reuse, automatic cleanup of idle connections,
|
|
24
|
+
health checks, and background maintenance.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
max_connections_per_host: int = 3,
|
|
30
|
+
idle_timeout: int = 300, # 5 minutes
|
|
31
|
+
cleanup_interval: int = 60, # 1 minute
|
|
32
|
+
health_check_interval: int = 30, # 30 seconds
|
|
33
|
+
enable_background_cleanup: bool = True,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Initialize connection pool.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
max_connections_per_host: Maximum connections per host.
|
|
40
|
+
idle_timeout: Time in seconds before idle connections are closed.
|
|
41
|
+
cleanup_interval: Interval for background cleanup thread.
|
|
42
|
+
health_check_interval: Interval for health checks on connections.
|
|
43
|
+
enable_background_cleanup: Whether to enable background cleanup thread.
|
|
44
|
+
"""
|
|
45
|
+
self.max_connections_per_host = max_connections_per_host
|
|
46
|
+
self.idle_timeout = idle_timeout
|
|
47
|
+
self.cleanup_interval = cleanup_interval
|
|
48
|
+
self.health_check_interval = health_check_interval
|
|
49
|
+
|
|
50
|
+
self._connections: dict[str, list[tuple[SSHClient, float]]] = {}
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
self._machines: dict[str, MachineConfig] = {}
|
|
53
|
+
self._shutdown = threading.Event()
|
|
54
|
+
self._cleanup_thread: threading.Thread | None = None
|
|
55
|
+
self._health_callbacks: list[Callable[[str, bool], None]] = []
|
|
56
|
+
|
|
57
|
+
if enable_background_cleanup:
|
|
58
|
+
self._start_background_cleanup()
|
|
59
|
+
atexit.register(self.shutdown)
|
|
60
|
+
|
|
61
|
+
def register_machine(self, machine: MachineConfig) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Register a machine configuration.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
machine: Machine configuration to register.
|
|
67
|
+
"""
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._machines[machine.name] = machine
|
|
70
|
+
if machine.name not in self._connections:
|
|
71
|
+
self._connections[machine.name] = []
|
|
72
|
+
|
|
73
|
+
logger.info("pool_machine_registered", machine=machine.name)
|
|
74
|
+
|
|
75
|
+
def get_client(self, name: str) -> SSHClient:
|
|
76
|
+
"""
|
|
77
|
+
Get an SSH client for the specified machine.
|
|
78
|
+
|
|
79
|
+
Returns an existing connection if available, or creates a new one.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
name: Machine name.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
SSHClient connected to the machine.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
SSHConnectionError: If machine not found or connection fails.
|
|
89
|
+
"""
|
|
90
|
+
with self._lock:
|
|
91
|
+
if name not in self._machines:
|
|
92
|
+
raise SSHConnectionError(f"Machine not registered: {name}")
|
|
93
|
+
|
|
94
|
+
machine = self._machines[name]
|
|
95
|
+
|
|
96
|
+
# Try to get an existing connection
|
|
97
|
+
if name in self._connections:
|
|
98
|
+
connections = self._connections[name]
|
|
99
|
+
while connections:
|
|
100
|
+
client, last_used = connections.pop(0)
|
|
101
|
+
if client.is_connected:
|
|
102
|
+
logger.debug("pool_reusing_connection", machine=name)
|
|
103
|
+
return client
|
|
104
|
+
else:
|
|
105
|
+
# Connection was closed, discard it
|
|
106
|
+
try:
|
|
107
|
+
client.disconnect()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
# Create new connection
|
|
112
|
+
logger.info("pool_creating_connection", machine=name)
|
|
113
|
+
client = SSHClient(machine)
|
|
114
|
+
client.connect()
|
|
115
|
+
return client
|
|
116
|
+
|
|
117
|
+
def release_client(self, client: SSHClient) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Return a client to the pool for reuse.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
client: SSHClient to release.
|
|
123
|
+
"""
|
|
124
|
+
name = client.machine.name
|
|
125
|
+
|
|
126
|
+
with self._lock:
|
|
127
|
+
if name not in self._connections:
|
|
128
|
+
self._connections[name] = []
|
|
129
|
+
|
|
130
|
+
connections = self._connections[name]
|
|
131
|
+
|
|
132
|
+
# Check if we have room for more connections
|
|
133
|
+
if len(connections) < self.max_connections_per_host:
|
|
134
|
+
if client.is_connected:
|
|
135
|
+
connections.append((client, time.time()))
|
|
136
|
+
logger.debug("pool_connection_released", machine=name)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Pool is full or connection is dead, close it
|
|
140
|
+
try:
|
|
141
|
+
client.disconnect()
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
def cleanup_idle(self) -> int:
|
|
146
|
+
"""
|
|
147
|
+
Close connections that have been idle too long.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Number of connections closed.
|
|
151
|
+
"""
|
|
152
|
+
closed = 0
|
|
153
|
+
current_time = time.time()
|
|
154
|
+
|
|
155
|
+
with self._lock:
|
|
156
|
+
for name, connections in self._connections.items():
|
|
157
|
+
active = []
|
|
158
|
+
for client, last_used in connections:
|
|
159
|
+
if current_time - last_used > self.idle_timeout:
|
|
160
|
+
try:
|
|
161
|
+
client.disconnect()
|
|
162
|
+
closed += 1
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
else:
|
|
166
|
+
active.append((client, last_used))
|
|
167
|
+
self._connections[name] = active
|
|
168
|
+
|
|
169
|
+
if closed > 0:
|
|
170
|
+
logger.info("pool_cleanup", closed_connections=closed)
|
|
171
|
+
|
|
172
|
+
return closed
|
|
173
|
+
|
|
174
|
+
def close_all(self) -> None:
|
|
175
|
+
"""Close all connections in the pool."""
|
|
176
|
+
with self._lock:
|
|
177
|
+
for name, connections in self._connections.items():
|
|
178
|
+
for client, _ in connections:
|
|
179
|
+
try:
|
|
180
|
+
client.disconnect()
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
connections.clear()
|
|
184
|
+
|
|
185
|
+
logger.info("pool_closed_all")
|
|
186
|
+
|
|
187
|
+
def get_stats(self) -> dict:
|
|
188
|
+
"""
|
|
189
|
+
Get pool statistics.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Dictionary with pool statistics.
|
|
193
|
+
"""
|
|
194
|
+
with self._lock:
|
|
195
|
+
stats = {
|
|
196
|
+
"machines": len(self._machines),
|
|
197
|
+
"connections": {},
|
|
198
|
+
"background_cleanup_active": self._cleanup_thread is not None
|
|
199
|
+
and self._cleanup_thread.is_alive(),
|
|
200
|
+
}
|
|
201
|
+
for name, connections in self._connections.items():
|
|
202
|
+
active = sum(1 for c, _ in connections if c.is_connected)
|
|
203
|
+
stats["connections"][name] = {
|
|
204
|
+
"total": len(connections),
|
|
205
|
+
"active": active,
|
|
206
|
+
}
|
|
207
|
+
return stats
|
|
208
|
+
|
|
209
|
+
def _start_background_cleanup(self) -> None:
|
|
210
|
+
"""Start background cleanup thread."""
|
|
211
|
+
if self._cleanup_thread is not None and self._cleanup_thread.is_alive():
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
self._shutdown.clear()
|
|
215
|
+
self._cleanup_thread = threading.Thread(
|
|
216
|
+
target=self._background_cleanup_loop,
|
|
217
|
+
daemon=True,
|
|
218
|
+
name="ssh-pool-cleanup",
|
|
219
|
+
)
|
|
220
|
+
self._cleanup_thread.start()
|
|
221
|
+
logger.info("pool_background_cleanup_started")
|
|
222
|
+
|
|
223
|
+
def _background_cleanup_loop(self) -> None:
|
|
224
|
+
"""Background loop for cleanup and health checks."""
|
|
225
|
+
while not self._shutdown.is_set():
|
|
226
|
+
try:
|
|
227
|
+
self.cleanup_idle()
|
|
228
|
+
self._run_health_checks()
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.error("pool_background_error", error=str(e))
|
|
231
|
+
|
|
232
|
+
# Wait for next iteration or shutdown
|
|
233
|
+
self._shutdown.wait(timeout=self.cleanup_interval)
|
|
234
|
+
|
|
235
|
+
def _run_health_checks(self) -> None:
|
|
236
|
+
"""Run health checks on all pooled connections."""
|
|
237
|
+
with self._lock:
|
|
238
|
+
for name, connections in self._connections.items():
|
|
239
|
+
for client, _ in connections:
|
|
240
|
+
is_healthy = client.is_connected
|
|
241
|
+
for callback in self._health_callbacks:
|
|
242
|
+
try:
|
|
243
|
+
callback(name, is_healthy)
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
def register_health_callback(self, callback: Callable[[str, bool], None]) -> None:
|
|
248
|
+
"""
|
|
249
|
+
Register a callback for health check results.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
callback: Function called with (host_name, is_healthy).
|
|
253
|
+
"""
|
|
254
|
+
self._health_callbacks.append(callback)
|
|
255
|
+
|
|
256
|
+
def health_check(self, name: str) -> dict:
|
|
257
|
+
"""
|
|
258
|
+
Perform health check on a specific host.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
name: Machine name to check.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Dictionary with health status.
|
|
265
|
+
"""
|
|
266
|
+
with self._lock:
|
|
267
|
+
if name not in self._machines:
|
|
268
|
+
return {
|
|
269
|
+
"host": name,
|
|
270
|
+
"healthy": False,
|
|
271
|
+
"error": "Machine not registered",
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
machine = self._machines[name]
|
|
275
|
+
|
|
276
|
+
# Try to connect and execute simple command
|
|
277
|
+
try:
|
|
278
|
+
client = SSHClient(machine)
|
|
279
|
+
client.connect(retry=False)
|
|
280
|
+
result = client.execute("echo ok", timeout=5)
|
|
281
|
+
client.disconnect()
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
"host": name,
|
|
285
|
+
"healthy": result.exit_code == 0,
|
|
286
|
+
"latency_ms": result.duration_ms,
|
|
287
|
+
}
|
|
288
|
+
except Exception as e:
|
|
289
|
+
return {
|
|
290
|
+
"host": name,
|
|
291
|
+
"healthy": False,
|
|
292
|
+
"error": str(e),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
def shutdown(self) -> None:
|
|
296
|
+
"""Shutdown the pool and cleanup resources."""
|
|
297
|
+
logger.info("pool_shutting_down")
|
|
298
|
+
self._shutdown.set()
|
|
299
|
+
|
|
300
|
+
if self._cleanup_thread is not None:
|
|
301
|
+
self._cleanup_thread.join(timeout=5)
|
|
302
|
+
|
|
303
|
+
self.close_all()
|
|
304
|
+
logger.info("pool_shutdown_complete")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# Global connection pool instance
|
|
308
|
+
_pool: SSHConnectionPool | None = None
|
|
309
|
+
_pool_lock = threading.Lock()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_pool() -> SSHConnectionPool:
|
|
313
|
+
"""Get or create the global connection pool."""
|
|
314
|
+
global _pool
|
|
315
|
+
with _pool_lock:
|
|
316
|
+
if _pool is None:
|
|
317
|
+
_pool = SSHConnectionPool()
|
|
318
|
+
return _pool
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def init_pool(config: "MachinesConfig") -> SSHConnectionPool: # type: ignore
|
|
322
|
+
"""
|
|
323
|
+
Initialize the global connection pool with machines from config.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
config: MachinesConfig with machine definitions.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Initialized SSHConnectionPool.
|
|
330
|
+
"""
|
|
331
|
+
pool = get_pool()
|
|
332
|
+
for machine in config.machines:
|
|
333
|
+
pool.register_machine(machine)
|
|
334
|
+
return pool
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def reset_pool() -> None:
|
|
338
|
+
"""Reset the global pool (useful for testing)."""
|
|
339
|
+
global _pool
|
|
340
|
+
with _pool_lock:
|
|
341
|
+
if _pool is not None:
|
|
342
|
+
_pool.shutdown()
|
|
343
|
+
_pool = None
|