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/executor.py ADDED
@@ -0,0 +1,79 @@
1
+ """Command executor with security validation."""
2
+
3
+ import structlog
4
+
5
+ from sshmcp.models.command import CommandResult
6
+ from sshmcp.models.machine import MachineConfig
7
+ from sshmcp.ssh.client import SSHClient
8
+ from sshmcp.ssh.pool import get_pool
9
+
10
+ logger = structlog.get_logger()
11
+
12
+
13
+ class CommandExecutor:
14
+ """
15
+ Execute commands on remote servers with validation and pooling.
16
+ """
17
+
18
+ def __init__(self) -> None:
19
+ """Initialize executor."""
20
+ self._pool = get_pool()
21
+
22
+ def execute(
23
+ self,
24
+ machine: MachineConfig,
25
+ command: str,
26
+ timeout: int | None = None,
27
+ ) -> CommandResult:
28
+ """
29
+ Execute command on remote server.
30
+
31
+ Args:
32
+ machine: Machine configuration.
33
+ command: Command to execute.
34
+ timeout: Optional timeout override.
35
+
36
+ Returns:
37
+ CommandResult with execution details.
38
+
39
+ Raises:
40
+ SSHExecutionError: If execution fails.
41
+ """
42
+ client = self._pool.get_client(machine.name)
43
+
44
+ try:
45
+ result = client.execute(command, timeout=timeout)
46
+ return result
47
+ finally:
48
+ self._pool.release_client(client)
49
+
50
+ def execute_with_client(
51
+ self,
52
+ client: SSHClient,
53
+ command: str,
54
+ timeout: int | None = None,
55
+ ) -> CommandResult:
56
+ """
57
+ Execute command using provided client.
58
+
59
+ Args:
60
+ client: SSHClient to use.
61
+ command: Command to execute.
62
+ timeout: Optional timeout override.
63
+
64
+ Returns:
65
+ CommandResult with execution details.
66
+ """
67
+ return client.execute(command, timeout=timeout)
68
+
69
+
70
+ # Global executor instance
71
+ _executor: CommandExecutor | None = None
72
+
73
+
74
+ def get_executor() -> CommandExecutor:
75
+ """Get or create the global command executor."""
76
+ global _executor
77
+ if _executor is None:
78
+ _executor = CommandExecutor()
79
+ return _executor
@@ -0,0 +1,368 @@
1
+ """SSH port forwarding and tunneling support."""
2
+
3
+ import select
4
+ import socket
5
+ import threading
6
+
7
+ import paramiko
8
+ import structlog
9
+
10
+ from sshmcp.models.machine import MachineConfig
11
+ from sshmcp.ssh.client import SSHClient
12
+
13
+ logger = structlog.get_logger()
14
+
15
+
16
+ class PortForwardingError(Exception):
17
+ """Error in port forwarding."""
18
+
19
+ pass
20
+
21
+
22
+ class LocalForwarder:
23
+ """
24
+ Local port forwarding (SSH tunnel).
25
+
26
+ Forwards connections from a local port to a remote host:port via SSH.
27
+ Use case: Access remote database through SSH tunnel.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ ssh_client: SSHClient,
33
+ local_port: int,
34
+ remote_host: str,
35
+ remote_port: int,
36
+ local_bind: str = "127.0.0.1",
37
+ ) -> None:
38
+ """
39
+ Initialize local port forwarder.
40
+
41
+ Args:
42
+ ssh_client: Connected SSH client.
43
+ local_port: Local port to listen on.
44
+ remote_host: Remote host to forward to.
45
+ remote_port: Remote port to forward to.
46
+ local_bind: Local address to bind (default: localhost).
47
+ """
48
+ self.ssh_client = ssh_client
49
+ self.local_port = local_port
50
+ self.remote_host = remote_host
51
+ self.remote_port = remote_port
52
+ self.local_bind = local_bind
53
+
54
+ self._server_socket: socket.socket | None = None
55
+ self._running = False
56
+ self._thread: threading.Thread | None = None
57
+ self._active_channels: list[paramiko.Channel] = []
58
+
59
+ def start(self) -> None:
60
+ """Start the local port forwarding."""
61
+ if self._running:
62
+ return
63
+
64
+ if not self.ssh_client.is_connected:
65
+ raise PortForwardingError("SSH client not connected")
66
+
67
+ self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
68
+ self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
69
+
70
+ try:
71
+ self._server_socket.bind((self.local_bind, self.local_port))
72
+ self._server_socket.listen(5)
73
+ self._server_socket.settimeout(1.0)
74
+ except OSError as e:
75
+ raise PortForwardingError(
76
+ f"Failed to bind to {self.local_bind}:{self.local_port}: {e}"
77
+ )
78
+
79
+ self._running = True
80
+ self._thread = threading.Thread(target=self._accept_loop, daemon=True)
81
+ self._thread.start()
82
+
83
+ logger.info(
84
+ "local_forward_started",
85
+ local=f"{self.local_bind}:{self.local_port}",
86
+ remote=f"{self.remote_host}:{self.remote_port}",
87
+ )
88
+
89
+ def stop(self) -> None:
90
+ """Stop the local port forwarding."""
91
+ self._running = False
92
+
93
+ # Close all active channels
94
+ for channel in self._active_channels:
95
+ try:
96
+ channel.close()
97
+ except Exception:
98
+ pass
99
+ self._active_channels.clear()
100
+
101
+ # Close server socket
102
+ if self._server_socket:
103
+ try:
104
+ self._server_socket.close()
105
+ except Exception:
106
+ pass
107
+ self._server_socket = None
108
+
109
+ # Wait for thread
110
+ if self._thread:
111
+ self._thread.join(timeout=2)
112
+ self._thread = None
113
+
114
+ logger.info("local_forward_stopped")
115
+
116
+ def _accept_loop(self) -> None:
117
+ """Accept incoming connections and forward them."""
118
+ while self._running and self._server_socket:
119
+ try:
120
+ client_socket, client_addr = self._server_socket.accept()
121
+ logger.debug("local_forward_connection", client=client_addr)
122
+
123
+ # Start forwarding thread for this connection
124
+ thread = threading.Thread(
125
+ target=self._forward_connection,
126
+ args=(client_socket, client_addr),
127
+ daemon=True,
128
+ )
129
+ thread.start()
130
+
131
+ except socket.timeout:
132
+ continue
133
+ except Exception as e:
134
+ if self._running:
135
+ logger.error("local_forward_accept_error", error=str(e))
136
+
137
+ def _forward_connection(
138
+ self, client_socket: socket.socket, client_addr: tuple
139
+ ) -> None:
140
+ """Forward a single connection."""
141
+ channel = None
142
+ try:
143
+ transport = self.ssh_client._client.get_transport() # type: ignore
144
+ if not transport:
145
+ raise PortForwardingError("No SSH transport")
146
+
147
+ channel = transport.open_channel(
148
+ "direct-tcpip",
149
+ (self.remote_host, self.remote_port),
150
+ client_addr,
151
+ )
152
+ self._active_channels.append(channel)
153
+
154
+ # Forward data bidirectionally
155
+ while self._running:
156
+ r, w, x = select.select([client_socket, channel], [], [], 1.0)
157
+
158
+ if client_socket in r:
159
+ data = client_socket.recv(4096)
160
+ if len(data) == 0:
161
+ break
162
+ channel.send(data)
163
+
164
+ if channel in r:
165
+ data = channel.recv(4096)
166
+ if len(data) == 0:
167
+ break
168
+ client_socket.send(data)
169
+
170
+ except Exception as e:
171
+ logger.debug("local_forward_error", error=str(e))
172
+ finally:
173
+ if channel:
174
+ try:
175
+ self._active_channels.remove(channel)
176
+ channel.close()
177
+ except Exception:
178
+ pass
179
+ try:
180
+ client_socket.close()
181
+ except Exception:
182
+ pass
183
+
184
+ def __enter__(self) -> "LocalForwarder":
185
+ """Context manager entry."""
186
+ self.start()
187
+ return self
188
+
189
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
190
+ """Context manager exit."""
191
+ self.stop()
192
+
193
+
194
+ class RemoteForwarder:
195
+ """
196
+ Remote port forwarding (reverse tunnel).
197
+
198
+ Forwards connections from a remote port on SSH server to local host:port.
199
+ Use case: Expose local service to remote server.
200
+ """
201
+
202
+ def __init__(
203
+ self,
204
+ ssh_client: SSHClient,
205
+ remote_port: int,
206
+ local_host: str = "127.0.0.1",
207
+ local_port: int = 0,
208
+ remote_bind: str = "127.0.0.1",
209
+ ) -> None:
210
+ """
211
+ Initialize remote port forwarder.
212
+
213
+ Args:
214
+ ssh_client: Connected SSH client.
215
+ remote_port: Remote port to listen on.
216
+ local_host: Local host to forward to.
217
+ local_port: Local port to forward to (0 = same as remote).
218
+ remote_bind: Remote address to bind.
219
+ """
220
+ self.ssh_client = ssh_client
221
+ self.remote_port = remote_port
222
+ self.local_host = local_host
223
+ self.local_port = local_port or remote_port
224
+ self.remote_bind = remote_bind
225
+
226
+ self._running = False
227
+ self._thread: threading.Thread | None = None
228
+
229
+ def start(self) -> None:
230
+ """Start the remote port forwarding."""
231
+ if self._running:
232
+ return
233
+
234
+ if not self.ssh_client.is_connected:
235
+ raise PortForwardingError("SSH client not connected")
236
+
237
+ transport = self.ssh_client._client.get_transport() # type: ignore
238
+ if not transport:
239
+ raise PortForwardingError("No SSH transport")
240
+
241
+ try:
242
+ transport.request_port_forward(self.remote_bind, self.remote_port)
243
+ except paramiko.SSHException as e:
244
+ raise PortForwardingError(f"Failed to request remote port forward: {e}")
245
+
246
+ self._running = True
247
+ self._thread = threading.Thread(target=self._accept_loop, daemon=True)
248
+ self._thread.start()
249
+
250
+ logger.info(
251
+ "remote_forward_started",
252
+ remote=f"{self.remote_bind}:{self.remote_port}",
253
+ local=f"{self.local_host}:{self.local_port}",
254
+ )
255
+
256
+ def stop(self) -> None:
257
+ """Stop the remote port forwarding."""
258
+ self._running = False
259
+
260
+ if self.ssh_client.is_connected:
261
+ try:
262
+ transport = self.ssh_client._client.get_transport() # type: ignore
263
+ if transport:
264
+ transport.cancel_port_forward(self.remote_bind, self.remote_port)
265
+ except Exception:
266
+ pass
267
+
268
+ if self._thread:
269
+ self._thread.join(timeout=2)
270
+ self._thread = None
271
+
272
+ logger.info("remote_forward_stopped")
273
+
274
+ def _accept_loop(self) -> None:
275
+ """Accept incoming reverse tunnel connections."""
276
+ transport = self.ssh_client._client.get_transport() # type: ignore
277
+
278
+ while self._running and transport and transport.is_active():
279
+ try:
280
+ channel = transport.accept(timeout=1.0)
281
+ if channel is None:
282
+ continue
283
+
284
+ # Start forwarding thread
285
+ thread = threading.Thread(
286
+ target=self._forward_connection,
287
+ args=(channel,),
288
+ daemon=True,
289
+ )
290
+ thread.start()
291
+
292
+ except Exception as e:
293
+ if self._running:
294
+ logger.debug("remote_forward_accept_error", error=str(e))
295
+
296
+ def _forward_connection(self, channel: paramiko.Channel) -> None:
297
+ """Forward a single reverse tunnel connection."""
298
+ local_socket = None
299
+ try:
300
+ local_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
301
+ local_socket.connect((self.local_host, self.local_port))
302
+
303
+ while self._running:
304
+ r, w, x = select.select([local_socket, channel], [], [], 1.0)
305
+
306
+ if local_socket in r:
307
+ data = local_socket.recv(4096)
308
+ if len(data) == 0:
309
+ break
310
+ channel.send(data)
311
+
312
+ if channel in r:
313
+ data = channel.recv(4096)
314
+ if len(data) == 0:
315
+ break
316
+ local_socket.send(data)
317
+
318
+ except Exception as e:
319
+ logger.debug("remote_forward_error", error=str(e))
320
+ finally:
321
+ if local_socket:
322
+ try:
323
+ local_socket.close()
324
+ except Exception:
325
+ pass
326
+ try:
327
+ channel.close()
328
+ except Exception:
329
+ pass
330
+
331
+ def __enter__(self) -> "RemoteForwarder":
332
+ """Context manager entry."""
333
+ self.start()
334
+ return self
335
+
336
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
337
+ """Context manager exit."""
338
+ self.stop()
339
+
340
+
341
+ def create_tunnel(
342
+ machine: MachineConfig,
343
+ local_port: int,
344
+ remote_host: str,
345
+ remote_port: int,
346
+ ) -> LocalForwarder:
347
+ """
348
+ Create a local SSH tunnel.
349
+
350
+ Convenience function to create a tunnel to access remote services.
351
+
352
+ Args:
353
+ machine: Machine configuration for SSH server.
354
+ local_port: Local port to listen on.
355
+ remote_host: Remote host to connect to through tunnel.
356
+ remote_port: Remote port to connect to.
357
+
358
+ Returns:
359
+ LocalForwarder instance (not started).
360
+
361
+ Example:
362
+ >>> with create_tunnel(machine, 5433, "db.internal", 5432) as tunnel:
363
+ ... # Connect to localhost:5433 to reach db.internal:5432
364
+ ... pass
365
+ """
366
+ client = SSHClient(machine)
367
+ client.connect()
368
+ return LocalForwarder(client, local_port, remote_host, remote_port)