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
@@ -0,0 +1,392 @@
1
+ """TOTP-based two-factor authentication for critical operations."""
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ import threading
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+ import structlog
12
+
13
+ logger = structlog.get_logger()
14
+
15
+ # Optional imports for TOTP
16
+ try:
17
+ import pyotp
18
+
19
+ PYOTP_AVAILABLE = True
20
+ except ImportError:
21
+ PYOTP_AVAILABLE = False
22
+ pyotp = None # type: ignore
23
+
24
+ try:
25
+ import qrcode
26
+
27
+ QRCODE_AVAILABLE = True
28
+ except ImportError:
29
+ QRCODE_AVAILABLE = False
30
+ qrcode = None # type: ignore
31
+
32
+
33
+ class TOTPError(Exception):
34
+ """Error in TOTP operations."""
35
+
36
+ pass
37
+
38
+
39
+ class TOTPNotConfigured(TOTPError):
40
+ """TOTP is not configured for this host."""
41
+
42
+ pass
43
+
44
+
45
+ class TOTPVerificationFailed(TOTPError):
46
+ """TOTP verification failed."""
47
+
48
+ pass
49
+
50
+
51
+ class TOTPManager:
52
+ """
53
+ TOTP-based two-factor authentication manager.
54
+
55
+ Provides TOTP setup, verification, and management for critical SSH operations.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ secrets_file: str | None = None,
61
+ issuer: str = "SSH-MCP",
62
+ digits: int = 6,
63
+ interval: int = 30,
64
+ ) -> None:
65
+ """
66
+ Initialize TOTP manager.
67
+
68
+ Args:
69
+ secrets_file: Path to encrypted secrets file.
70
+ issuer: Issuer name for TOTP (shown in authenticator apps).
71
+ digits: Number of digits in TOTP code.
72
+ interval: Time interval in seconds.
73
+ """
74
+ if not PYOTP_AVAILABLE:
75
+ raise TOTPError(
76
+ "pyotp package is required for TOTP. Install with: pip install pyotp"
77
+ )
78
+
79
+ self.secrets_file = secrets_file or str(
80
+ Path.home() / ".sshmcp" / "totp_secrets.json"
81
+ )
82
+ self.issuer = issuer
83
+ self.digits = digits
84
+ self.interval = interval
85
+
86
+ self._secrets: dict[str, dict] = {}
87
+ self._lock = threading.Lock()
88
+ self._critical_commands: list[str] = [
89
+ r".*rm\s+-rf.*",
90
+ r".*shutdown.*",
91
+ r".*reboot.*",
92
+ r".*systemctl\s+(stop|disable).*",
93
+ r".*docker\s+(rm|stop|kill).*",
94
+ r".*DROP\s+DATABASE.*",
95
+ r".*DROP\s+TABLE.*",
96
+ ]
97
+ self._verification_callbacks: list[Callable[[str, bool], None]] = []
98
+
99
+ self._load_secrets()
100
+
101
+ def setup_totp(self, host: str, account_name: str | None = None) -> dict:
102
+ """
103
+ Set up TOTP for a host.
104
+
105
+ Args:
106
+ host: Host name to set up TOTP for.
107
+ account_name: Account name (default: host name).
108
+
109
+ Returns:
110
+ Dictionary with secret, provisioning URI, and optional QR code.
111
+ """
112
+ account = account_name or host
113
+ secret = pyotp.random_base32()
114
+
115
+ totp = pyotp.TOTP(secret, digits=self.digits, interval=self.interval)
116
+ uri = totp.provisioning_uri(name=account, issuer_name=self.issuer)
117
+
118
+ result = {
119
+ "host": host,
120
+ "secret": secret,
121
+ "uri": uri,
122
+ "account": account,
123
+ "issuer": self.issuer,
124
+ }
125
+
126
+ # Generate QR code if available
127
+ if QRCODE_AVAILABLE:
128
+ qr = qrcode.QRCode(version=1, box_size=10, border=5)
129
+ qr.add_data(uri)
130
+ qr.make(fit=True)
131
+
132
+ # Generate ASCII QR code
133
+ buffer = io.StringIO()
134
+ qr.print_ascii(out=buffer)
135
+ result["qr_ascii"] = buffer.getvalue()
136
+
137
+ # Save secret
138
+ with self._lock:
139
+ self._secrets[host] = {
140
+ "secret": secret,
141
+ "account": account,
142
+ "created_at": datetime.now(timezone.utc).isoformat(),
143
+ "enabled": True,
144
+ }
145
+ self._save_secrets()
146
+
147
+ logger.info("totp_setup", host=host)
148
+ return result
149
+
150
+ def verify_totp(self, host: str, code: str) -> bool:
151
+ """
152
+ Verify a TOTP code for a host.
153
+
154
+ Args:
155
+ host: Host name.
156
+ code: TOTP code to verify.
157
+
158
+ Returns:
159
+ True if verification successful.
160
+
161
+ Raises:
162
+ TOTPNotConfigured: If TOTP not set up for host.
163
+ TOTPVerificationFailed: If code is invalid.
164
+ """
165
+ with self._lock:
166
+ if host not in self._secrets:
167
+ raise TOTPNotConfigured(f"TOTP not configured for host: {host}")
168
+
169
+ host_config = self._secrets[host]
170
+ if not host_config.get("enabled", True):
171
+ return True # TOTP disabled for this host
172
+
173
+ secret = host_config["secret"]
174
+
175
+ totp = pyotp.TOTP(secret, digits=self.digits, interval=self.interval)
176
+
177
+ # Verify with 1-step window for clock drift
178
+ is_valid = totp.verify(code, valid_window=1)
179
+
180
+ # Notify callbacks
181
+ for callback in self._verification_callbacks:
182
+ try:
183
+ callback(host, is_valid)
184
+ except Exception:
185
+ pass
186
+
187
+ if not is_valid:
188
+ logger.warning("totp_verification_failed", host=host)
189
+ raise TOTPVerificationFailed("Invalid TOTP code")
190
+
191
+ logger.info("totp_verified", host=host)
192
+ return True
193
+
194
+ def is_totp_required(self, host: str) -> bool:
195
+ """
196
+ Check if TOTP is required for a host.
197
+
198
+ Args:
199
+ host: Host name.
200
+
201
+ Returns:
202
+ True if TOTP is configured and enabled.
203
+ """
204
+ with self._lock:
205
+ if host not in self._secrets:
206
+ return False
207
+ return self._secrets[host].get("enabled", True)
208
+
209
+ def is_critical_command(self, command: str) -> bool:
210
+ """
211
+ Check if a command is considered critical (requires TOTP).
212
+
213
+ Args:
214
+ command: Command to check.
215
+
216
+ Returns:
217
+ True if command is critical.
218
+ """
219
+ import re
220
+
221
+ command_lower = command.lower()
222
+ for pattern in self._critical_commands:
223
+ if re.match(pattern, command_lower, re.IGNORECASE):
224
+ return True
225
+ return False
226
+
227
+ def add_critical_pattern(self, pattern: str) -> None:
228
+ """
229
+ Add a pattern for critical commands.
230
+
231
+ Args:
232
+ pattern: Regex pattern to add.
233
+ """
234
+ self._critical_commands.append(pattern)
235
+
236
+ def remove_totp(self, host: str) -> bool:
237
+ """
238
+ Remove TOTP configuration for a host.
239
+
240
+ Args:
241
+ host: Host name.
242
+
243
+ Returns:
244
+ True if removed, False if not configured.
245
+ """
246
+ with self._lock:
247
+ if host not in self._secrets:
248
+ return False
249
+
250
+ del self._secrets[host]
251
+ self._save_secrets()
252
+
253
+ logger.info("totp_removed", host=host)
254
+ return True
255
+
256
+ def disable_totp(self, host: str) -> bool:
257
+ """
258
+ Temporarily disable TOTP for a host.
259
+
260
+ Args:
261
+ host: Host name.
262
+
263
+ Returns:
264
+ True if disabled.
265
+ """
266
+ with self._lock:
267
+ if host not in self._secrets:
268
+ return False
269
+
270
+ self._secrets[host]["enabled"] = False
271
+ self._save_secrets()
272
+
273
+ logger.info("totp_disabled", host=host)
274
+ return True
275
+
276
+ def enable_totp(self, host: str) -> bool:
277
+ """
278
+ Enable TOTP for a host.
279
+
280
+ Args:
281
+ host: Host name.
282
+
283
+ Returns:
284
+ True if enabled.
285
+ """
286
+ with self._lock:
287
+ if host not in self._secrets:
288
+ return False
289
+
290
+ self._secrets[host]["enabled"] = True
291
+ self._save_secrets()
292
+
293
+ logger.info("totp_enabled", host=host)
294
+ return True
295
+
296
+ def get_configured_hosts(self) -> list[str]:
297
+ """Get list of hosts with TOTP configured."""
298
+ with self._lock:
299
+ return list(self._secrets.keys())
300
+
301
+ def register_verification_callback(
302
+ self, callback: Callable[[str, bool], None]
303
+ ) -> None:
304
+ """
305
+ Register a callback for verification events.
306
+
307
+ Args:
308
+ callback: Function called with (host, success).
309
+ """
310
+ self._verification_callbacks.append(callback)
311
+
312
+ def _load_secrets(self) -> None:
313
+ """Load secrets from file."""
314
+ path = Path(self.secrets_file)
315
+ if not path.exists():
316
+ return
317
+
318
+ try:
319
+ with open(path) as f:
320
+ self._secrets = json.load(f)
321
+ logger.info("totp_secrets_loaded", count=len(self._secrets))
322
+ except Exception as e:
323
+ logger.error("totp_secrets_load_error", error=str(e))
324
+
325
+ def _save_secrets(self) -> None:
326
+ """Save secrets to file."""
327
+ path = Path(self.secrets_file)
328
+ path.parent.mkdir(parents=True, exist_ok=True)
329
+
330
+ try:
331
+ with open(path, "w") as f:
332
+ json.dump(self._secrets, f, indent=2)
333
+ # Set restrictive permissions
334
+ os.chmod(path, 0o600)
335
+ except Exception as e:
336
+ logger.error("totp_secrets_save_error", error=str(e))
337
+
338
+
339
+ # Global TOTP manager instance
340
+ _totp_manager: TOTPManager | None = None
341
+
342
+
343
+ def get_totp_manager() -> TOTPManager:
344
+ """Get or create the global TOTP manager."""
345
+ global _totp_manager
346
+ if _totp_manager is None:
347
+ _totp_manager = TOTPManager()
348
+ return _totp_manager
349
+
350
+
351
+ def init_totp_manager(
352
+ secrets_file: str | None = None,
353
+ issuer: str = "SSH-MCP",
354
+ ) -> TOTPManager:
355
+ """
356
+ Initialize the global TOTP manager.
357
+
358
+ Args:
359
+ secrets_file: Path to secrets file.
360
+ issuer: Issuer name for TOTP.
361
+
362
+ Returns:
363
+ Initialized TOTPManager.
364
+ """
365
+ global _totp_manager
366
+ _totp_manager = TOTPManager(secrets_file=secrets_file, issuer=issuer)
367
+ return _totp_manager
368
+
369
+
370
+ def require_totp(host: str, code: str | None = None) -> bool:
371
+ """
372
+ Check if TOTP is required and verify if code provided.
373
+
374
+ Args:
375
+ host: Host name.
376
+ code: Optional TOTP code.
377
+
378
+ Returns:
379
+ True if verified or not required.
380
+
381
+ Raises:
382
+ TOTPVerificationFailed: If code required but invalid/missing.
383
+ """
384
+ manager = get_totp_manager()
385
+
386
+ if not manager.is_totp_required(host):
387
+ return True
388
+
389
+ if code is None:
390
+ raise TOTPVerificationFailed(f"TOTP code required for host: {host}")
391
+
392
+ return manager.verify_totp(host, code)
@@ -0,0 +1,234 @@
1
+ """Security validation for commands and paths."""
2
+
3
+ import os
4
+ import re
5
+ from typing import List, Tuple
6
+
7
+ import structlog
8
+
9
+ from sshmcp.models.machine import SecurityConfig
10
+
11
+ logger = structlog.get_logger()
12
+
13
+
14
+ class ValidationError(Exception):
15
+ """Command or path validation failed."""
16
+
17
+ pass
18
+
19
+
20
+ def validate_command(
21
+ command: str,
22
+ security: SecurityConfig,
23
+ ) -> Tuple[bool, str | None]:
24
+ """
25
+ Validate command against security rules.
26
+
27
+ Args:
28
+ command: Command to validate.
29
+ security: Security configuration with allowed/forbidden patterns.
30
+
31
+ Returns:
32
+ Tuple of (is_valid, error_message).
33
+ """
34
+ command = command.strip()
35
+
36
+ if not command:
37
+ return False, "Empty command"
38
+
39
+ # Check forbidden commands first (blacklist)
40
+ for pattern in security.forbidden_commands:
41
+ try:
42
+ if re.match(pattern, command, re.IGNORECASE):
43
+ logger.warning(
44
+ "command_forbidden",
45
+ command=command,
46
+ pattern=pattern,
47
+ )
48
+ return False, f"Command matches forbidden pattern: {pattern}"
49
+ except re.error as e:
50
+ logger.error("invalid_regex_pattern", pattern=pattern, error=str(e))
51
+ continue
52
+
53
+ # If no allowed commands specified, allow all (except forbidden)
54
+ if not security.allowed_commands:
55
+ return True, None
56
+
57
+ # Check allowed commands (whitelist)
58
+ for pattern in security.allowed_commands:
59
+ try:
60
+ if re.match(pattern, command, re.IGNORECASE):
61
+ logger.debug(
62
+ "command_allowed",
63
+ command=command,
64
+ pattern=pattern,
65
+ )
66
+ return True, None
67
+ except re.error as e:
68
+ logger.error("invalid_regex_pattern", pattern=pattern, error=str(e))
69
+ continue
70
+
71
+ logger.warning(
72
+ "command_not_allowed",
73
+ command=command,
74
+ )
75
+ return False, "Command not in allowed list"
76
+
77
+
78
+ def validate_path(
79
+ path: str,
80
+ security: SecurityConfig,
81
+ check_type: str = "read",
82
+ ) -> Tuple[bool, str | None]:
83
+ """
84
+ Validate file path against security rules.
85
+
86
+ Args:
87
+ path: Path to validate.
88
+ security: Security configuration with allowed/forbidden paths.
89
+ check_type: Type of operation ('read' or 'write').
90
+
91
+ Returns:
92
+ Tuple of (is_valid, error_message).
93
+ """
94
+ if not path:
95
+ return False, "Empty path"
96
+
97
+ # Normalize path
98
+ normalized = normalize_path(path)
99
+
100
+ # Check for path traversal attacks
101
+ if ".." in normalized or normalized.startswith("../"):
102
+ logger.warning(
103
+ "path_traversal_attempt",
104
+ path=path,
105
+ normalized=normalized,
106
+ )
107
+ return False, "Path traversal not allowed"
108
+
109
+ # Check forbidden paths
110
+ for forbidden in security.forbidden_paths:
111
+ forbidden_normalized = normalize_path(forbidden)
112
+ if (
113
+ normalized.startswith(forbidden_normalized)
114
+ or normalized == forbidden_normalized
115
+ ):
116
+ logger.warning(
117
+ "path_forbidden",
118
+ path=path,
119
+ forbidden=forbidden,
120
+ )
121
+ return False, f"Path is forbidden: {forbidden}"
122
+
123
+ # If no allowed paths specified, allow all (except forbidden)
124
+ if not security.allowed_paths:
125
+ return True, None
126
+
127
+ # Check allowed paths
128
+ for allowed in security.allowed_paths:
129
+ allowed_normalized = normalize_path(allowed)
130
+ if (
131
+ normalized.startswith(allowed_normalized)
132
+ or normalized == allowed_normalized
133
+ ):
134
+ logger.debug(
135
+ "path_allowed",
136
+ path=path,
137
+ allowed=allowed,
138
+ )
139
+ return True, None
140
+
141
+ logger.warning(
142
+ "path_not_allowed",
143
+ path=path,
144
+ )
145
+ return False, "Path not in allowed list"
146
+
147
+
148
+ def normalize_path(path: str) -> str:
149
+ """
150
+ Normalize a file path.
151
+
152
+ Args:
153
+ path: Path to normalize.
154
+
155
+ Returns:
156
+ Normalized path.
157
+ """
158
+ # Expand user home directory
159
+ if path.startswith("~"):
160
+ path = os.path.expanduser(path)
161
+
162
+ # Remove trailing slashes
163
+ path = path.rstrip("/")
164
+
165
+ # Collapse multiple slashes
166
+ while "//" in path:
167
+ path = path.replace("//", "/")
168
+
169
+ return path
170
+
171
+
172
+ def check_command_safety(command: str) -> List[str]:
173
+ """
174
+ Check command for potential safety issues.
175
+
176
+ Returns list of warnings (empty if no issues found).
177
+
178
+ Args:
179
+ command: Command to check.
180
+
181
+ Returns:
182
+ List of warning messages.
183
+ """
184
+ warnings = []
185
+
186
+ # Dangerous patterns to warn about
187
+ dangerous_patterns = [
188
+ (r"rm\s+-rf", "Recursive force delete detected"),
189
+ (r"rm\s+.*\*", "Wildcard delete detected"),
190
+ (r">\s*/dev/", "Writing to /dev/ detected"),
191
+ (r"chmod\s+777", "World-writable permissions detected"),
192
+ (r"\|\s*sh", "Piping to shell detected"),
193
+ (r"\|\s*bash", "Piping to bash detected"),
194
+ (r"curl.*\|\s*", "Curl piped to command detected"),
195
+ (r"wget.*\|\s*", "Wget piped to command detected"),
196
+ (r"eval\s+", "Eval command detected"),
197
+ (r";\s*rm\s+", "Command chaining with rm detected"),
198
+ (r"&&\s*rm\s+", "Command chaining with rm detected"),
199
+ (r"\$\(.*\)", "Command substitution detected"),
200
+ (r"`.*`", "Backtick command substitution detected"),
201
+ ]
202
+
203
+ for pattern, warning in dangerous_patterns:
204
+ if re.search(pattern, command, re.IGNORECASE):
205
+ warnings.append(warning)
206
+
207
+ return warnings
208
+
209
+
210
+ def sanitize_command_for_log(command: str) -> str:
211
+ """
212
+ Sanitize command for logging (hide potential secrets).
213
+
214
+ Args:
215
+ command: Command to sanitize.
216
+
217
+ Returns:
218
+ Sanitized command safe for logging.
219
+ """
220
+ # Patterns that might contain secrets
221
+ secret_patterns = [
222
+ (r"password[=:\s]+\S+", "password=***"),
223
+ (r"passwd[=:\s]+\S+", "passwd=***"),
224
+ (r"secret[=:\s]+\S+", "secret=***"),
225
+ (r"token[=:\s]+\S+", "token=***"),
226
+ (r"api[_-]?key[=:\s]+\S+", "api_key=***"),
227
+ (r"AWS_SECRET[=:\s]+\S+", "AWS_SECRET=***"),
228
+ ]
229
+
230
+ sanitized = command
231
+ for pattern, replacement in secret_patterns:
232
+ sanitized = re.sub(pattern, replacement, sanitized, flags=re.IGNORECASE)
233
+
234
+ return sanitized