linux-mcp-server 0.1.0a2__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.
- linux_mcp_server/__init__.py +4 -0
- linux_mcp_server/__main__.py +30 -0
- linux_mcp_server/audit.py +331 -0
- linux_mcp_server/connection/__init__.py +0 -0
- linux_mcp_server/connection/ssh.py +338 -0
- linux_mcp_server/logging_config.py +176 -0
- linux_mcp_server/server.py +18 -0
- linux_mcp_server/tools/__init__.py +51 -0
- linux_mcp_server/tools/logs.py +221 -0
- linux_mcp_server/tools/network.py +288 -0
- linux_mcp_server/tools/processes.py +273 -0
- linux_mcp_server/tools/services.py +149 -0
- linux_mcp_server/tools/storage.py +194 -0
- linux_mcp_server/tools/system_info.py +524 -0
- linux_mcp_server/utils/__init__.py +2 -0
- linux_mcp_server/utils/enum.py +16 -0
- linux_mcp_server/utils/format.py +26 -0
- linux_mcp_server/utils/types.py +5 -0
- linux_mcp_server/utils/validation.py +57 -0
- linux_mcp_server-0.1.0a2.dist-info/METADATA +233 -0
- linux_mcp_server-0.1.0a2.dist-info/RECORD +24 -0
- linux_mcp_server-0.1.0a2.dist-info/WHEEL +4 -0
- linux_mcp_server-0.1.0a2.dist-info/entry_points.txt +2 -0
- linux_mcp_server-0.1.0a2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Main entry point for the Linux MCP Server."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from linux_mcp_server import __version__
|
|
7
|
+
from linux_mcp_server.logging_config import setup_logging
|
|
8
|
+
from linux_mcp_server.server import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cli():
|
|
12
|
+
"""Console script entry point for the Linux MCP Server."""
|
|
13
|
+
setup_logging()
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("linux-mcp-server")
|
|
16
|
+
logger.info(f"Starting Linux MCP Server {__version__}")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
# FastMCP.run() creates its own event loop, don't use asyncio.run()
|
|
20
|
+
main()
|
|
21
|
+
except KeyboardInterrupt:
|
|
22
|
+
logger.info("Linux MCP Server stopped by user")
|
|
23
|
+
sys.exit(0)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
logger.critical(f"Fatal error in Linux MCP Server: {e}", exc_info=True)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
cli()
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Audit logging utilities for Linux MCP Server.
|
|
2
|
+
|
|
3
|
+
This module provides helper functions for consistent audit logging across
|
|
4
|
+
the entire MCP server. All functions add structured context to log records
|
|
5
|
+
that can be output in both human-readable and JSON formats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
import inspect
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
import typing as t
|
|
13
|
+
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
|
|
17
|
+
from linux_mcp_server.utils import StrEnum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
Function: t.TypeAlias = t.Callable[..., t.Any]
|
|
21
|
+
|
|
22
|
+
# Sensitive field names that should be redacted in logs
|
|
23
|
+
SENSITIVE_FIELDS = {
|
|
24
|
+
"password",
|
|
25
|
+
"passwd",
|
|
26
|
+
"pwd",
|
|
27
|
+
"secret",
|
|
28
|
+
"api_key",
|
|
29
|
+
"apikey",
|
|
30
|
+
"token",
|
|
31
|
+
"auth",
|
|
32
|
+
"authorization",
|
|
33
|
+
"private_key",
|
|
34
|
+
"privatekey",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Event(StrEnum):
|
|
39
|
+
LOCAL_EXEC_ERROR = "LOCAL_EXEC_ERROR"
|
|
40
|
+
REMOTE_EXEC = "REMOTE_EXEC"
|
|
41
|
+
REMOTE_EXEC_ERROR = "REMOTE_EXEC_ERROR"
|
|
42
|
+
SSH_AUTH_FAILED = "SSH_AUTH_FAILED"
|
|
43
|
+
SSH_CONNECT = "SSH_CONNECT"
|
|
44
|
+
SSH_CONNECTING = "SSH_CONNECTING"
|
|
45
|
+
TOOL_CALL = "TOOL_CALL"
|
|
46
|
+
TOOL_COMPLETE = "TOOL_COMPLETE"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ExecutionMode(StrEnum):
|
|
50
|
+
REMOTE = "REMOTE"
|
|
51
|
+
LOCAL = "LOCAL"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Status(StrEnum):
|
|
55
|
+
success = "success"
|
|
56
|
+
error = "error"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sanitize_parameters(params: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
60
|
+
"""
|
|
61
|
+
Sanitize parameters by redacting sensitive fields.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
params: Dictionary of parameters to sanitize
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Dictionary with sensitive fields redacted
|
|
68
|
+
"""
|
|
69
|
+
if not params:
|
|
70
|
+
return params
|
|
71
|
+
|
|
72
|
+
sanitized = {}
|
|
73
|
+
for key, value in params.items():
|
|
74
|
+
# Check if key is sensitive
|
|
75
|
+
key_lower = key.lower().replace("_", "").replace("-", "")
|
|
76
|
+
is_sensitive = any(sensitive in key_lower for sensitive in [s.replace("_", "") for s in SENSITIVE_FIELDS])
|
|
77
|
+
|
|
78
|
+
if is_sensitive:
|
|
79
|
+
sanitized[key] = "***REDACTED***"
|
|
80
|
+
elif isinstance(value, dict):
|
|
81
|
+
# Recursively sanitize nested dicts
|
|
82
|
+
sanitized[key] = sanitize_parameters(value)
|
|
83
|
+
else:
|
|
84
|
+
sanitized[key] = value
|
|
85
|
+
|
|
86
|
+
return sanitized
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@contextmanager
|
|
90
|
+
def AuditContext(**extra_fields):
|
|
91
|
+
"""
|
|
92
|
+
Context manager for adding extra fields to all log records.
|
|
93
|
+
|
|
94
|
+
Usage:
|
|
95
|
+
with AuditContext(tool="list_services", host="server1.com") as logger:
|
|
96
|
+
logger.info("Starting operation")
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
**extra_fields: Additional fields to add to log records
|
|
100
|
+
|
|
101
|
+
Yields:
|
|
102
|
+
Logger with extra fields
|
|
103
|
+
"""
|
|
104
|
+
logger = logging.getLogger()
|
|
105
|
+
|
|
106
|
+
# Create adapter with extra fields
|
|
107
|
+
class ContextAdapter(logging.LoggerAdapter):
|
|
108
|
+
def process(self, msg, kwargs):
|
|
109
|
+
# Add extra fields to the record
|
|
110
|
+
if "extra" not in kwargs:
|
|
111
|
+
kwargs["extra"] = {}
|
|
112
|
+
|
|
113
|
+
if isinstance(self.extra, t.Iterable):
|
|
114
|
+
kwargs["extra"].update(self.extra)
|
|
115
|
+
|
|
116
|
+
return msg, kwargs
|
|
117
|
+
|
|
118
|
+
adapter = ContextAdapter(logger, extra_fields)
|
|
119
|
+
yield adapter
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _log_event_start(
|
|
123
|
+
logger: logging.Logger,
|
|
124
|
+
tool_name: str,
|
|
125
|
+
params: dict[t.Any, t.Any],
|
|
126
|
+
) -> int:
|
|
127
|
+
"""
|
|
128
|
+
Emit a log event and return a performance counter timestamp.
|
|
129
|
+
|
|
130
|
+
The timestamp is in nanoseconds. It is meant to be used to calculate
|
|
131
|
+
total execution time.
|
|
132
|
+
"""
|
|
133
|
+
execution_mode = ExecutionMode.REMOTE if params.get("host") else ExecutionMode.LOCAL
|
|
134
|
+
safe_params = sanitize_parameters(params)
|
|
135
|
+
|
|
136
|
+
extra = {
|
|
137
|
+
"tool": tool_name,
|
|
138
|
+
"execution_mode": execution_mode,
|
|
139
|
+
}
|
|
140
|
+
if "host" in params:
|
|
141
|
+
extra["host"] = params["host"]
|
|
142
|
+
|
|
143
|
+
if "username" in params:
|
|
144
|
+
extra["username"] = params["username"]
|
|
145
|
+
|
|
146
|
+
message = f"{Event.TOOL_CALL}: {tool_name}"
|
|
147
|
+
|
|
148
|
+
params_str = ", ".join(f"{k}={v}" for k, v in safe_params.items() if k not in ["host", "username"])
|
|
149
|
+
if params_str:
|
|
150
|
+
message += f" | {params_str}"
|
|
151
|
+
|
|
152
|
+
logger.info(message, extra=extra)
|
|
153
|
+
|
|
154
|
+
return time.perf_counter_ns()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _log_event_complete(
|
|
158
|
+
logger: logging.Logger,
|
|
159
|
+
tool_name: str,
|
|
160
|
+
start_time: int,
|
|
161
|
+
error: Exception | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Log the completion of a tool call and calculate the total execution time.
|
|
165
|
+
"""
|
|
166
|
+
stop_time = time.perf_counter_ns()
|
|
167
|
+
duration = timedelta(microseconds=(stop_time - start_time) / 1_000)
|
|
168
|
+
status = Status.error if error else Status.success
|
|
169
|
+
extra = {
|
|
170
|
+
"tool": tool_name,
|
|
171
|
+
"status": status,
|
|
172
|
+
"duration": f"{duration}s",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
message = f"{Event.TOOL_COMPLETE}: {tool_name}"
|
|
176
|
+
|
|
177
|
+
if error:
|
|
178
|
+
extra["error"] = str(error)
|
|
179
|
+
message += f" | error: {error}"
|
|
180
|
+
logger.error(message, extra=extra)
|
|
181
|
+
else:
|
|
182
|
+
logger.info(message, extra=extra)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def log_tool_call(func: t.Callable) -> Function:
|
|
186
|
+
"""Decorator to log tool calls
|
|
187
|
+
|
|
188
|
+
Works with sync or async functions.
|
|
189
|
+
"""
|
|
190
|
+
logger = logging.getLogger("linux-mcp-server")
|
|
191
|
+
tool_name = func.__name__
|
|
192
|
+
|
|
193
|
+
@functools.wraps(func)
|
|
194
|
+
def wrapper(*args, **kwargs):
|
|
195
|
+
start_time = _log_event_start(logger, tool_name, kwargs)
|
|
196
|
+
error = None
|
|
197
|
+
result = None
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
result = func(*args, **kwargs)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
error = exc
|
|
203
|
+
_log_event_complete(logger, tool_name, start_time, error)
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
_log_event_complete(logger, tool_name, start_time, error)
|
|
207
|
+
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
@functools.wraps(func)
|
|
211
|
+
async def awrapper(*args, **kwargs):
|
|
212
|
+
start_time = _log_event_start(logger, tool_name, kwargs)
|
|
213
|
+
error = None
|
|
214
|
+
result = None
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
result = await func(*args, **kwargs)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
error = exc
|
|
220
|
+
_log_event_complete(logger, tool_name, start_time, error)
|
|
221
|
+
raise
|
|
222
|
+
|
|
223
|
+
_log_event_complete(logger, tool_name, start_time, error)
|
|
224
|
+
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
if inspect.iscoroutinefunction(func):
|
|
228
|
+
return awrapper
|
|
229
|
+
|
|
230
|
+
return wrapper
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def log_ssh_connect(
|
|
234
|
+
host: str,
|
|
235
|
+
username: str,
|
|
236
|
+
status: str,
|
|
237
|
+
reused: bool = False,
|
|
238
|
+
key_path: str | None = None,
|
|
239
|
+
error: str | None = None,
|
|
240
|
+
):
|
|
241
|
+
"""
|
|
242
|
+
Log SSH connection event.
|
|
243
|
+
|
|
244
|
+
Verbosity is tiered based on log level:
|
|
245
|
+
- INFO: Basic connection success/failure
|
|
246
|
+
- DEBUG: Detailed information including key path, reuse status
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
host: Remote host
|
|
250
|
+
username: SSH username
|
|
251
|
+
status: Connection status ("success" or "failed")
|
|
252
|
+
reused: Whether connection was reused (shown at DEBUG level)
|
|
253
|
+
key_path: Path to SSH key used (shown at DEBUG level)
|
|
254
|
+
error: Optional error message
|
|
255
|
+
"""
|
|
256
|
+
logger = logging.getLogger(__name__)
|
|
257
|
+
|
|
258
|
+
user_host = f"{username}@{host}"
|
|
259
|
+
|
|
260
|
+
if status == Status.success:
|
|
261
|
+
extra = {
|
|
262
|
+
"host": host,
|
|
263
|
+
"username": username,
|
|
264
|
+
"status": status,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# At INFO level, just log basic success
|
|
268
|
+
message = f"{Event.SSH_CONNECT}: {user_host}"
|
|
269
|
+
|
|
270
|
+
# At DEBUG level, add more details
|
|
271
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
272
|
+
if reused is not None:
|
|
273
|
+
extra["reused"] = str(reused)
|
|
274
|
+
if key_path:
|
|
275
|
+
extra["key"] = key_path
|
|
276
|
+
|
|
277
|
+
logger.info(message, extra=extra)
|
|
278
|
+
|
|
279
|
+
else:
|
|
280
|
+
# Connection failed
|
|
281
|
+
extra = {
|
|
282
|
+
"host": host,
|
|
283
|
+
"username": username,
|
|
284
|
+
"status": "failed",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if error:
|
|
288
|
+
extra["reason"] = error
|
|
289
|
+
|
|
290
|
+
message = f"{Event.SSH_AUTH_FAILED}: {user_host}"
|
|
291
|
+
if error:
|
|
292
|
+
message += f" | reason: {error}"
|
|
293
|
+
|
|
294
|
+
logger.warning(message, extra=extra)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def log_ssh_command(
|
|
298
|
+
command: str,
|
|
299
|
+
host: str,
|
|
300
|
+
exit_code: int,
|
|
301
|
+
duration: float | None = None,
|
|
302
|
+
):
|
|
303
|
+
"""
|
|
304
|
+
Log SSH command execution.
|
|
305
|
+
|
|
306
|
+
Verbosity is tiered based on log level:
|
|
307
|
+
- INFO: Command and exit code
|
|
308
|
+
- DEBUG: Also includes execution duration
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
command: Command that was executed
|
|
312
|
+
host: Remote host
|
|
313
|
+
exit_code: Command exit code
|
|
314
|
+
duration: Optional execution duration in seconds (shown at DEBUG level)
|
|
315
|
+
"""
|
|
316
|
+
logger = logging.getLogger(__name__)
|
|
317
|
+
|
|
318
|
+
extra = {
|
|
319
|
+
"command": command,
|
|
320
|
+
"host": host,
|
|
321
|
+
"exit_code": exit_code,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
message = f"{Event.REMOTE_EXEC}: {command} | host={host} | exit_code={exit_code}"
|
|
325
|
+
|
|
326
|
+
# At DEBUG level, include duration
|
|
327
|
+
if duration is not None and logger.isEnabledFor(logging.DEBUG):
|
|
328
|
+
extra["duration"] = f"{duration:.3f}s"
|
|
329
|
+
message += f" | duration={duration:.3f}s"
|
|
330
|
+
|
|
331
|
+
logger.info(message, extra=extra)
|
|
File without changes
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""SSH executor for remote command execution.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to execute commands on remote systems via SSH,
|
|
4
|
+
with connection pooling and SSH key discovery. It seamlessly routes commands to
|
|
5
|
+
either local or remote execution based on the provided parameters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import getpass
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import asyncssh
|
|
20
|
+
|
|
21
|
+
from linux_mcp_server.audit import Event
|
|
22
|
+
from linux_mcp_server.audit import log_ssh_command
|
|
23
|
+
from linux_mcp_server.audit import log_ssh_connect
|
|
24
|
+
from linux_mcp_server.audit import Status
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("linux-mcp-server")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def discover_ssh_key() -> str | None:
|
|
31
|
+
"""
|
|
32
|
+
Discover SSH private key for authentication.
|
|
33
|
+
|
|
34
|
+
Checks in order:
|
|
35
|
+
1. LINUX_MCP_SSH_KEY_PATH environment variable
|
|
36
|
+
2. Default locations: ~/.ssh/id_ed25519, ~/.ssh/id_rsa, ~/.ssh/id_ecdsa
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Path to SSH private key if found, None otherwise.
|
|
40
|
+
"""
|
|
41
|
+
logger.debug("Discovering SSH key for authentication")
|
|
42
|
+
|
|
43
|
+
# Check environment variable first
|
|
44
|
+
env_key = os.getenv("LINUX_MCP_SSH_KEY_PATH")
|
|
45
|
+
if env_key:
|
|
46
|
+
logger.debug(f"Checking SSH key from environment: {env_key}")
|
|
47
|
+
key_path = Path(env_key)
|
|
48
|
+
if key_path.exists() and key_path.is_file():
|
|
49
|
+
logger.info(f"Using SSH key from environment: {env_key}")
|
|
50
|
+
return str(key_path)
|
|
51
|
+
else:
|
|
52
|
+
logger.warning(f"SSH key specified in LINUX_MCP_SSH_KEY_PATH not found: {env_key}")
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
# Check default locations (prefer modern algorithms)
|
|
56
|
+
if os.getenv("LINUX_MCP_SEARCH_FOR_SSH_KEY", False):
|
|
57
|
+
home = Path.home()
|
|
58
|
+
default_keys = [
|
|
59
|
+
home / ".ssh" / "id_ed25519",
|
|
60
|
+
home / ".ssh" / "id_ecdsa",
|
|
61
|
+
home / ".ssh" / "id_rsa",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
logger.debug(f"Checking default SSH key locations: {[str(k) for k in default_keys]}")
|
|
65
|
+
|
|
66
|
+
for key_path in default_keys:
|
|
67
|
+
if key_path.exists() and key_path.is_file():
|
|
68
|
+
logger.info(f"Using SSH key: {key_path}")
|
|
69
|
+
return str(key_path)
|
|
70
|
+
|
|
71
|
+
logger.warning("No SSH private key found in default locations")
|
|
72
|
+
|
|
73
|
+
logger.debug("Not providing an SSH key")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SSHConnectionManager:
|
|
77
|
+
"""
|
|
78
|
+
Manages SSH connections with connection pooling.
|
|
79
|
+
|
|
80
|
+
This class implements a singleton pattern to maintain a pool of SSH connections
|
|
81
|
+
across the lifetime of the application, improving performance by reusing
|
|
82
|
+
connections to the same hosts.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
_instance: Optional["SSHConnectionManager"] = None
|
|
86
|
+
_connections: dict[str, asyncssh.SSHClientConnection]
|
|
87
|
+
_ssh_key: str | None
|
|
88
|
+
|
|
89
|
+
def __new__(cls):
|
|
90
|
+
"""Implement singleton pattern."""
|
|
91
|
+
if cls._instance is None:
|
|
92
|
+
cls._instance = super().__new__(cls)
|
|
93
|
+
cls._instance._connections = {}
|
|
94
|
+
cls._instance._ssh_key = discover_ssh_key()
|
|
95
|
+
return cls._instance
|
|
96
|
+
|
|
97
|
+
async def get_connection(self, host: str, username: str) -> asyncssh.SSHClientConnection:
|
|
98
|
+
"""
|
|
99
|
+
Get or create an SSH connection to a host.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
host: Remote host address
|
|
103
|
+
username: SSH username
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
SSH connection object
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ConnectionError: If connection fails
|
|
110
|
+
"""
|
|
111
|
+
key = f"{username}@{host}"
|
|
112
|
+
|
|
113
|
+
# Return existing connection if available
|
|
114
|
+
if key in self._connections:
|
|
115
|
+
conn = self._connections[key]
|
|
116
|
+
if not conn.is_closed():
|
|
117
|
+
# DEBUG level: Log connection reuse and pool state
|
|
118
|
+
logger.debug(f"SSH_REUSE: {key} | pool_size={len(self._connections)}")
|
|
119
|
+
# Use audit log with connection reuse info
|
|
120
|
+
log_ssh_connect(host, username, status=Status.success, reused=True, key_path=self._ssh_key)
|
|
121
|
+
return conn
|
|
122
|
+
else:
|
|
123
|
+
# Connection was closed, remove it
|
|
124
|
+
logger.debug(f"SSH_POOL: remove_closed_connection | connection={key}")
|
|
125
|
+
del self._connections[key]
|
|
126
|
+
|
|
127
|
+
# Create new connection
|
|
128
|
+
# DEBUG level: Log connection attempt before it completes
|
|
129
|
+
logger.debug(f"{Event.SSH_CONNECTING}: {key} | key={self._ssh_key or 'none'}")
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
connect_kwargs = {
|
|
133
|
+
"host": host,
|
|
134
|
+
"username": username,
|
|
135
|
+
"known_hosts": None, # Don't verify host keys for now
|
|
136
|
+
"passphrase": os.getenv("LINUX_MCP_KEY_PASSPHRASE"),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if self._ssh_key:
|
|
140
|
+
connect_kwargs["client_keys"] = [self._ssh_key]
|
|
141
|
+
|
|
142
|
+
conn = await asyncssh.connect(**connect_kwargs)
|
|
143
|
+
self._connections[key] = conn
|
|
144
|
+
|
|
145
|
+
# Log successful connection using audit function
|
|
146
|
+
log_ssh_connect(host, username, status=Status.success, reused=False, key_path=self._ssh_key)
|
|
147
|
+
|
|
148
|
+
# DEBUG level: Log pool state
|
|
149
|
+
logger.debug(f"SSH_POOL: add_connection | connections={len(self._connections)}")
|
|
150
|
+
|
|
151
|
+
return conn
|
|
152
|
+
|
|
153
|
+
except asyncssh.PermissionDenied as e:
|
|
154
|
+
# Use audit log for authentication failure
|
|
155
|
+
error_msg = str(e)
|
|
156
|
+
log_ssh_connect(host, username, status="failed", error=f"Permission denied: {error_msg}")
|
|
157
|
+
raise ConnectionError(f"Authentication failed for {username}@{host}") from e
|
|
158
|
+
except asyncssh.Error as e:
|
|
159
|
+
# Use audit log for connection failure
|
|
160
|
+
error_msg = str(e)
|
|
161
|
+
log_ssh_connect(host, username, status="failed", error=error_msg)
|
|
162
|
+
raise ConnectionError(f"Failed to connect to {username}@{host}: {e}") from e
|
|
163
|
+
|
|
164
|
+
async def execute_remote(
|
|
165
|
+
self,
|
|
166
|
+
command: list[str],
|
|
167
|
+
host: str,
|
|
168
|
+
username: str,
|
|
169
|
+
) -> tuple[int, str, str]:
|
|
170
|
+
"""
|
|
171
|
+
Execute a command on a remote host via SSH.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
command: Command and arguments to execute
|
|
175
|
+
host: Remote host address
|
|
176
|
+
username: SSH username
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Tuple of (return_code, stdout, stderr)
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ConnectionError: If SSH connection fails
|
|
183
|
+
"""
|
|
184
|
+
conn = await self.get_connection(host, username)
|
|
185
|
+
|
|
186
|
+
# Build command string with proper shell escaping
|
|
187
|
+
# Use shlex.quote() to ensure special characters (like \n in printf format) are preserved
|
|
188
|
+
cmd_str = shlex.join(command)
|
|
189
|
+
|
|
190
|
+
# Start timing for command execution
|
|
191
|
+
start_time = time.time()
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
result = await conn.run(cmd_str, check=False)
|
|
195
|
+
|
|
196
|
+
return_code = result.exit_status if result.exit_status is not None else 0
|
|
197
|
+
|
|
198
|
+
# Ensure stdout and stderr are strings (asyncssh can return bytes or str)
|
|
199
|
+
stdout_raw = result.stdout if result.stdout else ""
|
|
200
|
+
stderr_raw = result.stderr if result.stderr else ""
|
|
201
|
+
stdout = stdout_raw if isinstance(stdout_raw, str) else stdout_raw.decode("utf-8", errors="replace")
|
|
202
|
+
stderr = stderr_raw if isinstance(stderr_raw, str) else stderr_raw.decode("utf-8", errors="replace")
|
|
203
|
+
|
|
204
|
+
# Calculate duration
|
|
205
|
+
duration = time.time() - start_time
|
|
206
|
+
|
|
207
|
+
# Use audit log for command execution
|
|
208
|
+
log_ssh_command(cmd_str, host, exit_code=return_code, duration=duration)
|
|
209
|
+
|
|
210
|
+
return return_code, stdout, stderr
|
|
211
|
+
|
|
212
|
+
except asyncssh.Error as e:
|
|
213
|
+
duration = time.time() - start_time
|
|
214
|
+
logger.error(
|
|
215
|
+
f"Error executing command on {username}@{host}: {e}",
|
|
216
|
+
extra={
|
|
217
|
+
"event": Event.REMOTE_EXEC_ERROR,
|
|
218
|
+
"command": cmd_str,
|
|
219
|
+
"host": host,
|
|
220
|
+
"duration": f"{duration:.3f}s",
|
|
221
|
+
"error": str(e),
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
raise ConnectionError(f"Failed to execute command on {username}@{host}: {e}") from e
|
|
225
|
+
|
|
226
|
+
async def close_all(self):
|
|
227
|
+
"""Close all SSH connections."""
|
|
228
|
+
connection_count = len(self._connections)
|
|
229
|
+
logger.info(f"Closing {connection_count} SSH connections")
|
|
230
|
+
|
|
231
|
+
for key, conn in list(self._connections.items()):
|
|
232
|
+
try:
|
|
233
|
+
logger.debug(f"SSH_CLOSE: {key}")
|
|
234
|
+
conn.close()
|
|
235
|
+
await conn.wait_closed()
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.warning(f"Error closing connection to {key}: {e}")
|
|
238
|
+
|
|
239
|
+
self._connections.clear()
|
|
240
|
+
logger.debug(f"SSH_POOL: cleared | closed_connections={connection_count}")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Global connection manager instance
|
|
244
|
+
_connection_manager = SSHConnectionManager()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def execute_command(
|
|
248
|
+
command: list[str],
|
|
249
|
+
host: str | None = None,
|
|
250
|
+
username: str | None = None,
|
|
251
|
+
**kwargs,
|
|
252
|
+
) -> tuple[int, str, str]:
|
|
253
|
+
"""
|
|
254
|
+
Execute a command locally or remotely.
|
|
255
|
+
|
|
256
|
+
This is the main entry point for command execution. It routes the command
|
|
257
|
+
to either local subprocess execution or remote SSH execution based on
|
|
258
|
+
whether host/username parameters are provided.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
command: Command and arguments to execute
|
|
262
|
+
host: Optional remote host address
|
|
263
|
+
username: Optional SSH username (required if host is provided)
|
|
264
|
+
**kwargs: Additional arguments (reserved for future use)
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Tuple of (return_code, stdout, stderr)
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
ValueError: If host is provided without username
|
|
271
|
+
ConnectionError: If remote connection fails
|
|
272
|
+
|
|
273
|
+
Examples:
|
|
274
|
+
# Local execution
|
|
275
|
+
>>> returncode, stdout, stderr = await execute_command(["ls", "-la"])
|
|
276
|
+
|
|
277
|
+
# Remote execution
|
|
278
|
+
>>> returncode, stdout, stderr = await execute_command(
|
|
279
|
+
... ["ls", "-la"],
|
|
280
|
+
... host="server.example.com",
|
|
281
|
+
... username="admin"
|
|
282
|
+
... )
|
|
283
|
+
"""
|
|
284
|
+
cmd_str = " ".join(command)
|
|
285
|
+
|
|
286
|
+
# Route to remote execution if host is provided
|
|
287
|
+
if host:
|
|
288
|
+
if not username:
|
|
289
|
+
username = getpass.getuser()
|
|
290
|
+
|
|
291
|
+
logger.debug(f"Routing to remote execution: {username}@{host} | command={cmd_str}")
|
|
292
|
+
return await _connection_manager.execute_remote(command, host, username)
|
|
293
|
+
|
|
294
|
+
# Local execution
|
|
295
|
+
logger.debug(f"LOCAL_EXEC: {cmd_str}")
|
|
296
|
+
return await _execute_local(command)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def _execute_local(command: list[str]) -> tuple[int, str, str]:
|
|
300
|
+
"""
|
|
301
|
+
Execute a command locally using subprocess.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
command: Command and arguments to execute
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Tuple of (return_code, stdout, stderr)
|
|
308
|
+
"""
|
|
309
|
+
cmd_str = " ".join(command)
|
|
310
|
+
start_time = time.time()
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
proc = await asyncio.create_subprocess_exec(*command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
314
|
+
stdout_bytes, stderr_bytes = await proc.communicate()
|
|
315
|
+
|
|
316
|
+
return_code = proc.returncode if proc.returncode is not None else 0
|
|
317
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
|
318
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
319
|
+
|
|
320
|
+
duration = time.time() - start_time
|
|
321
|
+
|
|
322
|
+
# DEBUG level: Log local command execution with timing
|
|
323
|
+
logger.debug(f"LOCAL_EXEC completed: {cmd_str} | exit_code={return_code} | duration={duration:.3f}s")
|
|
324
|
+
|
|
325
|
+
return return_code, stdout, stderr
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
duration = time.time() - start_time
|
|
329
|
+
logger.error(
|
|
330
|
+
f"Error executing local command: {cmd_str}",
|
|
331
|
+
extra={
|
|
332
|
+
"event": Event.LOCAL_EXEC_ERROR,
|
|
333
|
+
"command": cmd_str,
|
|
334
|
+
"duration": f"{duration:.3f}s",
|
|
335
|
+
"error": str(e),
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
return 1, "", str(e)
|