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/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
|
sshmcp/ssh/forwarding.py
ADDED
|
@@ -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)
|