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.
Files changed (47) hide show
  1. mcp_ssh_vps-0.4.1.dist-info/METADATA +482 -0
  2. mcp_ssh_vps-0.4.1.dist-info/RECORD +47 -0
  3. mcp_ssh_vps-0.4.1.dist-info/WHEEL +5 -0
  4. mcp_ssh_vps-0.4.1.dist-info/entry_points.txt +4 -0
  5. mcp_ssh_vps-0.4.1.dist-info/licenses/LICENSE +21 -0
  6. mcp_ssh_vps-0.4.1.dist-info/top_level.txt +1 -0
  7. sshmcp/__init__.py +3 -0
  8. sshmcp/cli.py +473 -0
  9. sshmcp/config.py +155 -0
  10. sshmcp/core/__init__.py +5 -0
  11. sshmcp/core/container.py +291 -0
  12. sshmcp/models/__init__.py +15 -0
  13. sshmcp/models/command.py +69 -0
  14. sshmcp/models/file.py +102 -0
  15. sshmcp/models/machine.py +139 -0
  16. sshmcp/monitoring/__init__.py +0 -0
  17. sshmcp/monitoring/alerts.py +464 -0
  18. sshmcp/prompts/__init__.py +7 -0
  19. sshmcp/prompts/backup.py +151 -0
  20. sshmcp/prompts/deploy.py +115 -0
  21. sshmcp/prompts/monitor.py +146 -0
  22. sshmcp/resources/__init__.py +7 -0
  23. sshmcp/resources/logs.py +99 -0
  24. sshmcp/resources/metrics.py +204 -0
  25. sshmcp/resources/status.py +160 -0
  26. sshmcp/security/__init__.py +7 -0
  27. sshmcp/security/audit.py +314 -0
  28. sshmcp/security/rate_limiter.py +221 -0
  29. sshmcp/security/totp.py +392 -0
  30. sshmcp/security/validator.py +234 -0
  31. sshmcp/security/whitelist.py +169 -0
  32. sshmcp/server.py +632 -0
  33. sshmcp/ssh/__init__.py +6 -0
  34. sshmcp/ssh/async_client.py +247 -0
  35. sshmcp/ssh/client.py +464 -0
  36. sshmcp/ssh/executor.py +79 -0
  37. sshmcp/ssh/forwarding.py +368 -0
  38. sshmcp/ssh/pool.py +343 -0
  39. sshmcp/ssh/shell.py +518 -0
  40. sshmcp/ssh/transfer.py +461 -0
  41. sshmcp/tools/__init__.py +13 -0
  42. sshmcp/tools/commands.py +226 -0
  43. sshmcp/tools/files.py +220 -0
  44. sshmcp/tools/helpers.py +321 -0
  45. sshmcp/tools/history.py +372 -0
  46. sshmcp/tools/processes.py +214 -0
  47. 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