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/core/container.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Dependency injection container for sshmcp."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
from sshmcp.models.machine import MachinesConfig
|
|
10
|
+
from sshmcp.security.audit import AuditLogger
|
|
11
|
+
from sshmcp.security.rate_limiter import RateLimiter
|
|
12
|
+
from sshmcp.ssh.pool import SSHConnectionPool
|
|
13
|
+
from sshmcp.ssh.shell import ShellManager
|
|
14
|
+
|
|
15
|
+
logger = structlog.get_logger()
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Provider(Generic[T]):
|
|
21
|
+
"""Lazy provider for dependency instances."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, factory: Callable[[], T]) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Initialize provider.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
factory: Factory function to create instance.
|
|
29
|
+
"""
|
|
30
|
+
self._factory = factory
|
|
31
|
+
self._instance: T | None = None
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def get(self) -> T:
|
|
35
|
+
"""Get or create the instance."""
|
|
36
|
+
if self._instance is None:
|
|
37
|
+
with self._lock:
|
|
38
|
+
if self._instance is None:
|
|
39
|
+
self._instance = self._factory()
|
|
40
|
+
return self._instance
|
|
41
|
+
|
|
42
|
+
def reset(self) -> None:
|
|
43
|
+
"""Reset the instance."""
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._instance = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_initialized(self) -> bool:
|
|
49
|
+
"""Check if instance is initialized."""
|
|
50
|
+
return self._instance is not None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ContainerConfig:
|
|
55
|
+
"""Configuration for the container."""
|
|
56
|
+
|
|
57
|
+
# Pool settings
|
|
58
|
+
max_connections_per_host: int = 3
|
|
59
|
+
idle_timeout: int = 300
|
|
60
|
+
cleanup_interval: int = 60
|
|
61
|
+
health_check_interval: int = 30
|
|
62
|
+
enable_background_cleanup: bool = True
|
|
63
|
+
|
|
64
|
+
# Rate limiter settings
|
|
65
|
+
requests_per_minute: int = 60
|
|
66
|
+
requests_per_hour: int = 1000
|
|
67
|
+
|
|
68
|
+
# Audit settings
|
|
69
|
+
audit_log_path: str | None = None
|
|
70
|
+
audit_retention_days: int = 30
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Container:
|
|
74
|
+
"""
|
|
75
|
+
Dependency injection container.
|
|
76
|
+
|
|
77
|
+
Manages lifecycle of shared services like connection pool,
|
|
78
|
+
shell manager, audit logger, rate limiter, etc.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, config: ContainerConfig | None = None) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Initialize container.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
config: Optional configuration.
|
|
87
|
+
"""
|
|
88
|
+
self.config = config or ContainerConfig()
|
|
89
|
+
self._providers: dict[str, Provider[Any]] = {}
|
|
90
|
+
self._machines_config: MachinesConfig | None = None
|
|
91
|
+
self._lock = threading.Lock()
|
|
92
|
+
|
|
93
|
+
self._register_defaults()
|
|
94
|
+
|
|
95
|
+
def _register_defaults(self) -> None:
|
|
96
|
+
"""Register default providers."""
|
|
97
|
+
self._providers["pool"] = Provider(self._create_pool)
|
|
98
|
+
self._providers["shell_manager"] = Provider(self._create_shell_manager)
|
|
99
|
+
self._providers["audit_logger"] = Provider(self._create_audit_logger)
|
|
100
|
+
self._providers["rate_limiter"] = Provider(self._create_rate_limiter)
|
|
101
|
+
|
|
102
|
+
def _create_pool(self) -> SSHConnectionPool:
|
|
103
|
+
"""Create connection pool."""
|
|
104
|
+
pool = SSHConnectionPool(
|
|
105
|
+
max_connections_per_host=self.config.max_connections_per_host,
|
|
106
|
+
idle_timeout=self.config.idle_timeout,
|
|
107
|
+
cleanup_interval=self.config.cleanup_interval,
|
|
108
|
+
health_check_interval=self.config.health_check_interval,
|
|
109
|
+
enable_background_cleanup=self.config.enable_background_cleanup,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Register machines if config available
|
|
113
|
+
if self._machines_config:
|
|
114
|
+
for machine in self._machines_config.machines:
|
|
115
|
+
pool.register_machine(machine)
|
|
116
|
+
|
|
117
|
+
logger.info("container_pool_created")
|
|
118
|
+
return pool
|
|
119
|
+
|
|
120
|
+
def _create_shell_manager(self) -> ShellManager:
|
|
121
|
+
"""Create shell manager."""
|
|
122
|
+
logger.info("container_shell_manager_created")
|
|
123
|
+
return ShellManager()
|
|
124
|
+
|
|
125
|
+
def _create_audit_logger(self) -> AuditLogger:
|
|
126
|
+
"""Create audit logger."""
|
|
127
|
+
logger.info("container_audit_logger_created")
|
|
128
|
+
return AuditLogger(log_file=self.config.audit_log_path)
|
|
129
|
+
|
|
130
|
+
def _create_rate_limiter(self) -> RateLimiter:
|
|
131
|
+
"""Create rate limiter."""
|
|
132
|
+
from sshmcp.security.rate_limiter import RateLimitConfig
|
|
133
|
+
|
|
134
|
+
logger.info("container_rate_limiter_created")
|
|
135
|
+
config = RateLimitConfig(
|
|
136
|
+
requests_per_minute=self.config.requests_per_minute,
|
|
137
|
+
requests_per_hour=self.config.requests_per_hour,
|
|
138
|
+
)
|
|
139
|
+
return RateLimiter(config=config)
|
|
140
|
+
|
|
141
|
+
def set_machines_config(self, config: MachinesConfig) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Set machines configuration.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
config: Machines configuration.
|
|
147
|
+
"""
|
|
148
|
+
self._machines_config = config
|
|
149
|
+
|
|
150
|
+
# If pool already exists, register machines
|
|
151
|
+
if self._providers["pool"].is_initialized:
|
|
152
|
+
pool = self.pool
|
|
153
|
+
for machine in config.machines:
|
|
154
|
+
pool.register_machine(machine)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def pool(self) -> SSHConnectionPool:
|
|
158
|
+
"""Get connection pool."""
|
|
159
|
+
return self._providers["pool"].get()
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def shell_manager(self) -> ShellManager:
|
|
163
|
+
"""Get shell manager."""
|
|
164
|
+
return self._providers["shell_manager"].get()
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def audit_logger(self) -> AuditLogger:
|
|
168
|
+
"""Get audit logger."""
|
|
169
|
+
return self._providers["audit_logger"].get()
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def rate_limiter(self) -> RateLimiter:
|
|
173
|
+
"""Get rate limiter."""
|
|
174
|
+
return self._providers["rate_limiter"].get()
|
|
175
|
+
|
|
176
|
+
def register(self, name: str, factory: Callable[[], Any]) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Register a custom provider.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
name: Provider name.
|
|
182
|
+
factory: Factory function.
|
|
183
|
+
"""
|
|
184
|
+
with self._lock:
|
|
185
|
+
self._providers[name] = Provider(factory)
|
|
186
|
+
|
|
187
|
+
def get(self, name: str) -> Any:
|
|
188
|
+
"""
|
|
189
|
+
Get a service by name.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
name: Service name.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Service instance.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
KeyError: If service not registered.
|
|
199
|
+
"""
|
|
200
|
+
if name not in self._providers:
|
|
201
|
+
raise KeyError(f"Service not registered: {name}")
|
|
202
|
+
return self._providers[name].get()
|
|
203
|
+
|
|
204
|
+
def reset(self, name: str | None = None) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Reset service(s).
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
name: Service name to reset, or None to reset all.
|
|
210
|
+
"""
|
|
211
|
+
with self._lock:
|
|
212
|
+
if name:
|
|
213
|
+
if name in self._providers:
|
|
214
|
+
self._providers[name].reset()
|
|
215
|
+
else:
|
|
216
|
+
for provider in self._providers.values():
|
|
217
|
+
provider.reset()
|
|
218
|
+
|
|
219
|
+
def shutdown(self) -> None:
|
|
220
|
+
"""Shutdown all services."""
|
|
221
|
+
logger.info("container_shutting_down")
|
|
222
|
+
|
|
223
|
+
# Shutdown pool
|
|
224
|
+
if self._providers["pool"].is_initialized:
|
|
225
|
+
self.pool.shutdown()
|
|
226
|
+
|
|
227
|
+
# Close shell sessions
|
|
228
|
+
if self._providers["shell_manager"].is_initialized:
|
|
229
|
+
self.shell_manager.close_all()
|
|
230
|
+
|
|
231
|
+
# Reset all
|
|
232
|
+
self.reset()
|
|
233
|
+
logger.info("container_shutdown_complete")
|
|
234
|
+
|
|
235
|
+
def __enter__(self) -> "Container":
|
|
236
|
+
"""Context manager entry."""
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
240
|
+
"""Context manager exit."""
|
|
241
|
+
self.shutdown()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Global container instance
|
|
245
|
+
_container: Container | None = None
|
|
246
|
+
_container_lock = threading.Lock()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def get_container() -> Container:
|
|
250
|
+
"""Get or create the global container."""
|
|
251
|
+
global _container
|
|
252
|
+
with _container_lock:
|
|
253
|
+
if _container is None:
|
|
254
|
+
_container = Container()
|
|
255
|
+
return _container
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def init_container(
|
|
259
|
+
config: ContainerConfig | None = None,
|
|
260
|
+
machines: MachinesConfig | None = None,
|
|
261
|
+
) -> Container:
|
|
262
|
+
"""
|
|
263
|
+
Initialize the global container.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
config: Container configuration.
|
|
267
|
+
machines: Machines configuration.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Initialized Container.
|
|
271
|
+
"""
|
|
272
|
+
global _container
|
|
273
|
+
with _container_lock:
|
|
274
|
+
if _container is not None:
|
|
275
|
+
_container.shutdown()
|
|
276
|
+
|
|
277
|
+
_container = Container(config=config)
|
|
278
|
+
|
|
279
|
+
if machines:
|
|
280
|
+
_container.set_machines_config(machines)
|
|
281
|
+
|
|
282
|
+
return _container
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def reset_container() -> None:
|
|
286
|
+
"""Reset the global container."""
|
|
287
|
+
global _container
|
|
288
|
+
with _container_lock:
|
|
289
|
+
if _container is not None:
|
|
290
|
+
_container.shutdown()
|
|
291
|
+
_container = None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Pydantic models for SSH MCP Server."""
|
|
2
|
+
|
|
3
|
+
from sshmcp.models.command import CommandError, CommandResult
|
|
4
|
+
from sshmcp.models.file import FileContent, FileInfo
|
|
5
|
+
from sshmcp.models.machine import AuthConfig, MachineConfig, SecurityConfig
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MachineConfig",
|
|
9
|
+
"AuthConfig",
|
|
10
|
+
"SecurityConfig",
|
|
11
|
+
"CommandResult",
|
|
12
|
+
"CommandError",
|
|
13
|
+
"FileInfo",
|
|
14
|
+
"FileContent",
|
|
15
|
+
]
|
sshmcp/models/command.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pydantic models for command execution results."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommandResult(BaseModel):
|
|
10
|
+
"""Result of command execution on remote server."""
|
|
11
|
+
|
|
12
|
+
exit_code: int = Field(description="Command exit code (0 = success)")
|
|
13
|
+
stdout: str = Field(default="", description="Standard output")
|
|
14
|
+
stderr: str = Field(default="", description="Standard error output")
|
|
15
|
+
duration_ms: int = Field(ge=0, description="Execution duration in milliseconds")
|
|
16
|
+
host: str = Field(description="Host where command was executed")
|
|
17
|
+
command: str = Field(description="Executed command")
|
|
18
|
+
timestamp: datetime = Field(
|
|
19
|
+
default_factory=datetime.utcnow, description="Execution timestamp"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def success(self) -> bool:
|
|
24
|
+
"""Check if command executed successfully."""
|
|
25
|
+
return self.exit_code == 0
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
"""Convert to dictionary for MCP response."""
|
|
29
|
+
return {
|
|
30
|
+
"exit_code": self.exit_code,
|
|
31
|
+
"stdout": self.stdout,
|
|
32
|
+
"stderr": self.stderr,
|
|
33
|
+
"duration_ms": self.duration_ms,
|
|
34
|
+
"host": self.host,
|
|
35
|
+
"command": self.command,
|
|
36
|
+
"success": self.success,
|
|
37
|
+
"timestamp": self.timestamp.isoformat(),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CommandError(BaseModel):
|
|
42
|
+
"""Error details for failed command execution."""
|
|
43
|
+
|
|
44
|
+
error_type: str = Field(description="Type of error")
|
|
45
|
+
message: str = Field(description="Error message")
|
|
46
|
+
host: str | None = Field(default=None, description="Host where error occurred")
|
|
47
|
+
command: str | None = Field(default=None, description="Command that caused error")
|
|
48
|
+
details: dict[str, Any] | None = Field(
|
|
49
|
+
default=None, description="Additional error details"
|
|
50
|
+
)
|
|
51
|
+
timestamp: datetime = Field(
|
|
52
|
+
default_factory=datetime.utcnow, description="Error timestamp"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
"""Convert to dictionary for MCP response."""
|
|
57
|
+
result = {
|
|
58
|
+
"error": True,
|
|
59
|
+
"error_type": self.error_type,
|
|
60
|
+
"message": self.message,
|
|
61
|
+
"timestamp": self.timestamp.isoformat(),
|
|
62
|
+
}
|
|
63
|
+
if self.host:
|
|
64
|
+
result["host"] = self.host
|
|
65
|
+
if self.command:
|
|
66
|
+
result["command"] = self.command
|
|
67
|
+
if self.details:
|
|
68
|
+
result["details"] = self.details
|
|
69
|
+
return result
|
sshmcp/models/file.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Pydantic models for file operations."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileInfo(BaseModel):
|
|
10
|
+
"""Information about a file on remote server."""
|
|
11
|
+
|
|
12
|
+
name: str = Field(description="File name")
|
|
13
|
+
path: str = Field(description="Full path to file")
|
|
14
|
+
type: Literal["file", "directory", "link", "other"] = Field(description="File type")
|
|
15
|
+
size: int = Field(ge=0, description="File size in bytes")
|
|
16
|
+
modified: datetime = Field(description="Last modification time")
|
|
17
|
+
permissions: str | None = Field(
|
|
18
|
+
default=None, description="File permissions (e.g., '0644')"
|
|
19
|
+
)
|
|
20
|
+
owner: str | None = Field(default=None, description="File owner")
|
|
21
|
+
group: str | None = Field(default=None, description="File group")
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
"""Convert to dictionary for MCP response."""
|
|
25
|
+
result = {
|
|
26
|
+
"name": self.name,
|
|
27
|
+
"path": self.path,
|
|
28
|
+
"type": self.type,
|
|
29
|
+
"size": self.size,
|
|
30
|
+
"modified": self.modified.isoformat(),
|
|
31
|
+
}
|
|
32
|
+
if self.permissions:
|
|
33
|
+
result["permissions"] = self.permissions
|
|
34
|
+
if self.owner:
|
|
35
|
+
result["owner"] = self.owner
|
|
36
|
+
if self.group:
|
|
37
|
+
result["group"] = self.group
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FileContent(BaseModel):
|
|
42
|
+
"""Content of a file from remote server."""
|
|
43
|
+
|
|
44
|
+
content: str = Field(description="File content")
|
|
45
|
+
path: str = Field(description="Full path to file")
|
|
46
|
+
size: int = Field(ge=0, description="File size in bytes")
|
|
47
|
+
encoding: str = Field(default="utf-8", description="File encoding")
|
|
48
|
+
truncated: bool = Field(
|
|
49
|
+
default=False, description="Whether content was truncated due to size limit"
|
|
50
|
+
)
|
|
51
|
+
host: str = Field(description="Host where file is located")
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
"""Convert to dictionary for MCP response."""
|
|
55
|
+
return {
|
|
56
|
+
"content": self.content,
|
|
57
|
+
"path": self.path,
|
|
58
|
+
"size": self.size,
|
|
59
|
+
"encoding": self.encoding,
|
|
60
|
+
"truncated": self.truncated,
|
|
61
|
+
"host": self.host,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FileUploadResult(BaseModel):
|
|
66
|
+
"""Result of file upload operation."""
|
|
67
|
+
|
|
68
|
+
success: bool = Field(description="Whether upload was successful")
|
|
69
|
+
path: str = Field(description="Path where file was uploaded")
|
|
70
|
+
size: int = Field(ge=0, description="Uploaded file size in bytes")
|
|
71
|
+
host: str = Field(description="Host where file was uploaded")
|
|
72
|
+
message: str | None = Field(default=None, description="Additional message")
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict[str, Any]:
|
|
75
|
+
"""Convert to dictionary for MCP response."""
|
|
76
|
+
result = {
|
|
77
|
+
"success": self.success,
|
|
78
|
+
"path": self.path,
|
|
79
|
+
"size": self.size,
|
|
80
|
+
"host": self.host,
|
|
81
|
+
}
|
|
82
|
+
if self.message:
|
|
83
|
+
result["message"] = self.message
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FileListResult(BaseModel):
|
|
88
|
+
"""Result of listing files in directory."""
|
|
89
|
+
|
|
90
|
+
files: list[FileInfo] = Field(description="List of files")
|
|
91
|
+
directory: str = Field(description="Listed directory path")
|
|
92
|
+
host: str = Field(description="Host where files are located")
|
|
93
|
+
total_count: int = Field(ge=0, description="Total number of files")
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict[str, Any]:
|
|
96
|
+
"""Convert to dictionary for MCP response."""
|
|
97
|
+
return {
|
|
98
|
+
"files": [f.to_dict() for f in self.files],
|
|
99
|
+
"directory": self.directory,
|
|
100
|
+
"host": self.host,
|
|
101
|
+
"total_count": self.total_count,
|
|
102
|
+
}
|
sshmcp/models/machine.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Pydantic models for machine configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthConfig(BaseModel):
|
|
10
|
+
"""Authentication configuration for SSH connection."""
|
|
11
|
+
|
|
12
|
+
type: Literal["key", "password", "agent"] = Field(
|
|
13
|
+
description="Authentication type: 'key' for SSH key, 'password' for password, 'agent' for SSH agent"
|
|
14
|
+
)
|
|
15
|
+
key_path: str | None = Field(
|
|
16
|
+
default=None, description="Path to SSH private key file"
|
|
17
|
+
)
|
|
18
|
+
passphrase: str | None = Field(
|
|
19
|
+
default=None, description="Passphrase for encrypted SSH key"
|
|
20
|
+
)
|
|
21
|
+
password: str | None = Field(
|
|
22
|
+
default=None, description="Password for password authentication"
|
|
23
|
+
)
|
|
24
|
+
agent_forwarding: bool = Field(
|
|
25
|
+
default=False, description="Enable SSH agent forwarding"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@field_validator("key_path")
|
|
29
|
+
@classmethod
|
|
30
|
+
def expand_key_path(cls, v: str | None) -> str | None:
|
|
31
|
+
"""Expand ~ in key path."""
|
|
32
|
+
if v:
|
|
33
|
+
return os.path.expanduser(v)
|
|
34
|
+
return v
|
|
35
|
+
|
|
36
|
+
def model_post_init(self, __context: object) -> None:
|
|
37
|
+
"""Validate that required fields are present based on auth type."""
|
|
38
|
+
if self.type == "key" and not self.key_path:
|
|
39
|
+
raise ValueError("key_path is required when auth type is 'key'")
|
|
40
|
+
if self.type == "password" and not self.password:
|
|
41
|
+
raise ValueError("password is required when auth type is 'password'")
|
|
42
|
+
# 'agent' type doesn't require additional fields
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SecurityConfig(BaseModel):
|
|
46
|
+
"""Security configuration for command execution."""
|
|
47
|
+
|
|
48
|
+
allowed_commands: list[str] = Field(
|
|
49
|
+
default_factory=list,
|
|
50
|
+
description="List of regex patterns for allowed commands",
|
|
51
|
+
)
|
|
52
|
+
forbidden_commands: list[str] = Field(
|
|
53
|
+
default_factory=lambda: [
|
|
54
|
+
r".*rm\s+-rf\s+/.*",
|
|
55
|
+
r".*sudo\s+.*",
|
|
56
|
+
r".*su\s+-.*",
|
|
57
|
+
r".*dd\s+if=.*",
|
|
58
|
+
r".*mkfs\..*",
|
|
59
|
+
r".*:\(\)\{.*", # Fork bomb
|
|
60
|
+
],
|
|
61
|
+
description="List of regex patterns for forbidden commands",
|
|
62
|
+
)
|
|
63
|
+
timeout_seconds: int = Field(
|
|
64
|
+
default=30, ge=1, le=3600, description="Command timeout in seconds"
|
|
65
|
+
)
|
|
66
|
+
max_concurrent_commands: int = Field(
|
|
67
|
+
default=3, ge=1, le=10, description="Maximum concurrent commands per machine"
|
|
68
|
+
)
|
|
69
|
+
allowed_paths: list[str] = Field(
|
|
70
|
+
default_factory=list,
|
|
71
|
+
description="List of allowed file paths for read/write operations",
|
|
72
|
+
)
|
|
73
|
+
forbidden_paths: list[str] = Field(
|
|
74
|
+
default_factory=lambda: [
|
|
75
|
+
"/etc/passwd",
|
|
76
|
+
"/etc/shadow",
|
|
77
|
+
"/etc/sudoers",
|
|
78
|
+
"/root",
|
|
79
|
+
"~/.ssh",
|
|
80
|
+
],
|
|
81
|
+
description="List of forbidden file paths",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MachineConfig(BaseModel):
|
|
86
|
+
"""Configuration for a VPS machine."""
|
|
87
|
+
|
|
88
|
+
name: str = Field(
|
|
89
|
+
min_length=1, max_length=64, description="Unique name for the machine"
|
|
90
|
+
)
|
|
91
|
+
host: str = Field(min_length=1, description="Hostname or IP address")
|
|
92
|
+
port: int = Field(default=22, ge=1, le=65535, description="SSH port")
|
|
93
|
+
user: str = Field(min_length=1, description="SSH username")
|
|
94
|
+
auth: AuthConfig = Field(description="Authentication configuration")
|
|
95
|
+
security: SecurityConfig = Field(
|
|
96
|
+
default_factory=SecurityConfig, description="Security configuration"
|
|
97
|
+
)
|
|
98
|
+
description: str | None = Field(
|
|
99
|
+
default=None, description="Optional description of the machine"
|
|
100
|
+
)
|
|
101
|
+
tags: list[str] = Field(
|
|
102
|
+
default_factory=list,
|
|
103
|
+
description="Tags for grouping and filtering servers (e.g., ['production', 'web'])",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@field_validator("name")
|
|
107
|
+
@classmethod
|
|
108
|
+
def validate_name(cls, v: str) -> str:
|
|
109
|
+
"""Validate machine name contains only safe characters."""
|
|
110
|
+
import re
|
|
111
|
+
|
|
112
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", v):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"Machine name must contain only alphanumeric characters, underscores, and hyphens"
|
|
115
|
+
)
|
|
116
|
+
return v
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MachinesConfig(BaseModel):
|
|
120
|
+
"""Root configuration containing all machines."""
|
|
121
|
+
|
|
122
|
+
machines: list[MachineConfig] = Field(
|
|
123
|
+
default_factory=list, description="List of configured machines"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def get_machine(self, name: str) -> MachineConfig | None:
|
|
127
|
+
"""Get machine by name."""
|
|
128
|
+
for machine in self.machines:
|
|
129
|
+
if machine.name == name:
|
|
130
|
+
return machine
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def has_machine(self, name: str) -> bool:
|
|
134
|
+
"""Check if machine exists."""
|
|
135
|
+
return self.get_machine(name) is not None
|
|
136
|
+
|
|
137
|
+
def get_machine_names(self) -> list[str]:
|
|
138
|
+
"""Get list of all machine names."""
|
|
139
|
+
return [m.name for m in self.machines]
|
|
File without changes
|