linux-mcp-server 0.1.0.dev0__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.
@@ -0,0 +1,324 @@
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 logging
10
+ import os
11
+ import shlex
12
+ import subprocess
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Optional, Tuple
16
+
17
+ import asyncssh
18
+
19
+ from ..audit import log_ssh_connect, log_ssh_command
20
+
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def discover_ssh_key() -> Optional[str]:
26
+ """
27
+ Discover SSH private key for authentication.
28
+
29
+ Checks in order:
30
+ 1. LINUX_MCP_SSH_KEY_PATH environment variable
31
+ 2. Default locations: ~/.ssh/id_ed25519, ~/.ssh/id_rsa, ~/.ssh/id_ecdsa
32
+
33
+ Returns:
34
+ Path to SSH private key if found, None otherwise.
35
+ """
36
+ logger.debug("Discovering SSH key for authentication")
37
+
38
+ # Check environment variable first
39
+ env_key = os.getenv("LINUX_MCP_SSH_KEY_PATH")
40
+ if env_key:
41
+ logger.debug(f"Checking SSH key from environment: {env_key}")
42
+ key_path = Path(env_key)
43
+ if key_path.exists() and key_path.is_file():
44
+ logger.info(f"Using SSH key from environment: {env_key}")
45
+ return str(key_path)
46
+ else:
47
+ logger.warning(f"SSH key specified in LINUX_MCP_SSH_KEY_PATH not found: {env_key}")
48
+ return None
49
+
50
+ # Check default locations (prefer modern algorithms)
51
+ home = Path.home()
52
+ default_keys = [
53
+ home / ".ssh" / "id_ed25519",
54
+ home / ".ssh" / "id_ecdsa",
55
+ home / ".ssh" / "id_rsa",
56
+ ]
57
+
58
+ logger.debug(f"Checking default SSH key locations: {[str(k) for k in default_keys]}")
59
+
60
+ for key_path in default_keys:
61
+ if key_path.exists() and key_path.is_file():
62
+ logger.info(f"Using SSH key: {key_path}")
63
+ return str(key_path)
64
+
65
+ logger.warning("No SSH private key found in default locations")
66
+ return None
67
+
68
+
69
+ class SSHConnectionManager:
70
+ """
71
+ Manages SSH connections with connection pooling.
72
+
73
+ This class implements a singleton pattern to maintain a pool of SSH connections
74
+ across the lifetime of the application, improving performance by reusing
75
+ connections to the same hosts.
76
+ """
77
+
78
+ _instance = None
79
+
80
+ def __new__(cls):
81
+ """Implement singleton pattern."""
82
+ if cls._instance is None:
83
+ cls._instance = super().__new__(cls)
84
+ cls._instance._connections = {}
85
+ cls._instance._ssh_key = discover_ssh_key()
86
+ return cls._instance
87
+
88
+ async def get_connection(self, host: str, username: str) -> asyncssh.SSHClientConnection:
89
+ """
90
+ Get or create an SSH connection to a host.
91
+
92
+ Args:
93
+ host: Remote host address
94
+ username: SSH username
95
+
96
+ Returns:
97
+ SSH connection object
98
+
99
+ Raises:
100
+ ConnectionError: If connection fails
101
+ """
102
+ key = f"{username}@{host}"
103
+
104
+ # Return existing connection if available
105
+ if key in self._connections:
106
+ conn = self._connections[key]
107
+ if not conn.is_closed():
108
+ # DEBUG level: Log connection reuse and pool state
109
+ logger.debug(f"SSH_REUSE: {key} | pool_size={len(self._connections)}")
110
+ # Use audit log with connection reuse info
111
+ log_ssh_connect(host, username, status="success", reused=True, key_path=self._ssh_key)
112
+ return conn
113
+ else:
114
+ # Connection was closed, remove it
115
+ logger.debug(f"SSH_POOL: remove_closed_connection | connection={key}")
116
+ del self._connections[key]
117
+
118
+ # Create new connection
119
+ # DEBUG level: Log connection attempt before it completes
120
+ logger.debug(f"SSH_CONNECTING: {key} | key={self._ssh_key or 'none'}")
121
+
122
+ try:
123
+ connect_kwargs = {
124
+ "host": host,
125
+ "username": username,
126
+ "known_hosts": None, # Don't verify host keys for now
127
+ }
128
+
129
+ if self._ssh_key:
130
+ connect_kwargs["client_keys"] = [self._ssh_key]
131
+
132
+ conn = await asyncssh.connect(**connect_kwargs)
133
+ self._connections[key] = conn
134
+
135
+ # Log successful connection using audit function
136
+ log_ssh_connect(host, username, status="success", reused=False, key_path=self._ssh_key)
137
+
138
+ # DEBUG level: Log pool state
139
+ logger.debug(f"SSH_POOL: add_connection | connections={len(self._connections)}")
140
+
141
+ return conn
142
+
143
+ except asyncssh.PermissionDenied as e:
144
+ # Use audit log for authentication failure
145
+ error_msg = str(e)
146
+ log_ssh_connect(host, username, status="failed", error=f"Permission denied: {error_msg}")
147
+ raise ConnectionError(f"Authentication failed for {username}@{host}") from e
148
+ except asyncssh.Error as e:
149
+ # Use audit log for connection failure
150
+ error_msg = str(e)
151
+ log_ssh_connect(host, username, status="failed", error=error_msg)
152
+ raise ConnectionError(f"Failed to connect to {username}@{host}: {e}") from e
153
+
154
+ async def execute_remote(
155
+ self,
156
+ command: list[str],
157
+ host: str,
158
+ username: str
159
+ ) -> Tuple[int, str, str]:
160
+ """
161
+ Execute a command on a remote host via SSH.
162
+
163
+ Args:
164
+ command: Command and arguments to execute
165
+ host: Remote host address
166
+ username: SSH username
167
+
168
+ Returns:
169
+ Tuple of (return_code, stdout, stderr)
170
+
171
+ Raises:
172
+ ConnectionError: If SSH connection fails
173
+ """
174
+ conn = await self.get_connection(host, username)
175
+
176
+ # Build command string with proper shell escaping
177
+ # Use shlex.quote() to ensure special characters (like \n in printf format) are preserved
178
+ cmd_str = " ".join(shlex.quote(arg) for arg in command)
179
+
180
+ # Start timing for command execution
181
+ start_time = time.time()
182
+
183
+ try:
184
+ result = await conn.run(cmd_str, check=False)
185
+
186
+ return_code = result.exit_status if result.exit_status is not None else 0
187
+ stdout = result.stdout if result.stdout else ""
188
+ stderr = result.stderr if result.stderr else ""
189
+
190
+ # Calculate duration
191
+ duration = time.time() - start_time
192
+
193
+ # Use audit log for command execution
194
+ log_ssh_command(cmd_str, host, exit_code=return_code, duration=duration)
195
+
196
+ return return_code, stdout, stderr
197
+
198
+ except asyncssh.Error as e:
199
+ duration = time.time() - start_time
200
+ logger.error(f"Error executing command on {username}@{host}: {e}", extra={
201
+ 'event': 'REMOTE_EXEC_ERROR',
202
+ 'command': cmd_str,
203
+ 'host': host,
204
+ 'duration': f"{duration:.3f}s",
205
+ 'error': str(e)
206
+ })
207
+ raise ConnectionError(f"Failed to execute command on {username}@{host}: {e}") from e
208
+
209
+ async def close_all(self):
210
+ """Close all SSH connections."""
211
+ connection_count = len(self._connections)
212
+ logger.info(f"Closing {connection_count} SSH connections")
213
+
214
+ for key, conn in list(self._connections.items()):
215
+ try:
216
+ logger.debug(f"SSH_CLOSE: {key}")
217
+ conn.close()
218
+ await conn.wait_closed()
219
+ except Exception as e:
220
+ logger.warning(f"Error closing connection to {key}: {e}")
221
+
222
+ self._connections.clear()
223
+ logger.debug(f"SSH_POOL: cleared | closed_connections={connection_count}")
224
+
225
+
226
+ # Global connection manager instance
227
+ _connection_manager = SSHConnectionManager()
228
+
229
+
230
+ async def execute_command(
231
+ command: list[str],
232
+ host: Optional[str] = None,
233
+ username: Optional[str] = None,
234
+ **kwargs
235
+ ) -> Tuple[int, str, str]:
236
+ """
237
+ Execute a command locally or remotely.
238
+
239
+ This is the main entry point for command execution. It routes the command
240
+ to either local subprocess execution or remote SSH execution based on
241
+ whether host/username parameters are provided.
242
+
243
+ Args:
244
+ command: Command and arguments to execute
245
+ host: Optional remote host address
246
+ username: Optional SSH username (required if host is provided)
247
+ **kwargs: Additional arguments (reserved for future use)
248
+
249
+ Returns:
250
+ Tuple of (return_code, stdout, stderr)
251
+
252
+ Raises:
253
+ ValueError: If host is provided without username
254
+ ConnectionError: If remote connection fails
255
+
256
+ Examples:
257
+ # Local execution
258
+ >>> returncode, stdout, stderr = await execute_command(["ls", "-la"])
259
+
260
+ # Remote execution
261
+ >>> returncode, stdout, stderr = await execute_command(
262
+ ... ["ls", "-la"],
263
+ ... host="server.example.com",
264
+ ... username="admin"
265
+ ... )
266
+ """
267
+ cmd_str = " ".join(command)
268
+
269
+ # Route to remote execution if host is provided
270
+ if host:
271
+ if not username:
272
+ logger.error(f"Host provided without username for command: {cmd_str}")
273
+ raise ValueError("username is required when host is provided")
274
+
275
+ logger.debug(f"Routing to remote execution: {username}@{host} | command={cmd_str}")
276
+ return await _connection_manager.execute_remote(command, host, username)
277
+
278
+ # Local execution
279
+ logger.debug(f"LOCAL_EXEC: {cmd_str}")
280
+ return await _execute_local(command)
281
+
282
+
283
+ async def _execute_local(command: list[str]) -> Tuple[int, str, str]:
284
+ """
285
+ Execute a command locally using subprocess.
286
+
287
+ Args:
288
+ command: Command and arguments to execute
289
+
290
+ Returns:
291
+ Tuple of (return_code, stdout, stderr)
292
+ """
293
+ cmd_str = " ".join(command)
294
+ start_time = time.time()
295
+
296
+ try:
297
+ proc = await asyncio.create_subprocess_exec(
298
+ *command,
299
+ stdout=subprocess.PIPE,
300
+ stderr=subprocess.PIPE
301
+ )
302
+ stdout_bytes, stderr_bytes = await proc.communicate()
303
+
304
+ return_code = proc.returncode if proc.returncode is not None else 0
305
+ stdout = stdout_bytes.decode('utf-8', errors='replace')
306
+ stderr = stderr_bytes.decode('utf-8', errors='replace')
307
+
308
+ duration = time.time() - start_time
309
+
310
+ # DEBUG level: Log local command execution with timing
311
+ logger.debug(f"LOCAL_EXEC completed: {cmd_str} | exit_code={return_code} | duration={duration:.3f}s")
312
+
313
+ return return_code, stdout, stderr
314
+
315
+ except Exception as e:
316
+ duration = time.time() - start_time
317
+ logger.error(f"Error executing local command: {cmd_str}", extra={
318
+ 'event': 'LOCAL_EXEC_ERROR',
319
+ 'command': cmd_str,
320
+ 'duration': f"{duration:.3f}s",
321
+ 'error': str(e)
322
+ })
323
+ return 1, "", str(e)
324
+
@@ -0,0 +1,358 @@
1
+ """Storage and hardware tools."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import psutil
8
+
9
+ from .validation import validate_positive_int
10
+ from .ssh_executor import execute_command
11
+ from .utils import format_bytes
12
+
13
+
14
+ async def list_block_devices(host: Optional[str] = None, username: Optional[str] = None) -> str:
15
+ """
16
+ List block devices.
17
+
18
+ Args:
19
+ host: Optional remote host to connect to
20
+ username: Optional SSH username (required if host is provided)
21
+
22
+ Returns:
23
+ Formatted string with block device information
24
+ """
25
+ try:
26
+ # Try using lsblk first (most readable)
27
+ returncode, stdout, stderr = await execute_command(
28
+ ["lsblk", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,MODEL", "--no-pager"],
29
+ host=host,
30
+ username=username
31
+ )
32
+
33
+ if returncode == 0:
34
+ result = ["=== Block Devices ===\n"]
35
+ result.append(stdout)
36
+
37
+ # Add disk I/O per-disk stats if available (only for local execution)
38
+ if not host:
39
+ try:
40
+ disk_io_per_disk = psutil.disk_io_counters(perdisk=True)
41
+ if disk_io_per_disk:
42
+ result.append("\n=== Disk I/O Statistics (per disk) ===")
43
+ for disk, stats in sorted(disk_io_per_disk.items()):
44
+ result.append(f"\n{disk}:")
45
+ result.append(f" Read: {format_bytes(stats.read_bytes)}")
46
+ result.append(f" Write: {format_bytes(stats.write_bytes)}")
47
+ result.append(f" Read Count: {stats.read_count}")
48
+ result.append(f" Write Count: {stats.write_count}")
49
+ except Exception:
50
+ pass
51
+
52
+ return "\n".join(result)
53
+ else:
54
+ # Fallback to listing partitions with psutil
55
+ result = ["=== Block Devices (fallback) ===\n"]
56
+ partitions = psutil.disk_partitions(all=True)
57
+
58
+ for partition in partitions:
59
+ result.append(f"\nDevice: {partition.device}")
60
+ result.append(f" Mountpoint: {partition.mountpoint}")
61
+ result.append(f" Filesystem: {partition.fstype}")
62
+ result.append(f" Options: {partition.opts}")
63
+
64
+ return "\n".join(result)
65
+ except FileNotFoundError:
66
+ # If lsblk is not available, use psutil
67
+ result = ["=== Block Devices ===\n"]
68
+ partitions = psutil.disk_partitions(all=True)
69
+
70
+ for partition in partitions:
71
+ result.append(f"\nDevice: {partition.device}")
72
+ result.append(f" Mountpoint: {partition.mountpoint}")
73
+ result.append(f" Filesystem: {partition.fstype}")
74
+ result.append(f" Options: {partition.opts}")
75
+
76
+ return "\n".join(result)
77
+ except Exception as e:
78
+ return f"Error listing block devices: {str(e)}"
79
+
80
+
81
+ async def list_directories_by_size(
82
+ path: str,
83
+ top_n: int,
84
+ host: Optional[str] = None,
85
+ username: Optional[str] = None
86
+ ) -> str:
87
+ """
88
+ List directories under a specified path sorted by size (largest first).
89
+
90
+ This function uses efficient Linux primitives (du command) to calculate directory
91
+ sizes, making it much faster than Python-based directory traversal.
92
+
93
+ Args:
94
+ path: The directory path to analyze
95
+ top_n: Number of top directories to return (1-1000). Accepts int or float
96
+ (floats are truncated to integers)
97
+ host: Optional remote host to connect to
98
+ username: Optional SSH username (required if host is provided)
99
+
100
+ Returns:
101
+ Formatted string with directory sizes, or error message if validation fails
102
+
103
+ Security Features:
104
+ - Path validation and resolution using pathlib
105
+ - Command parameters passed as list (not shell string)
106
+ - Input sanitization for all parameters
107
+ - Graceful error handling for permission issues
108
+ """
109
+ import os
110
+ from pathlib import Path
111
+
112
+ try:
113
+ # Validate and normalize top_n parameter
114
+ top_n, error = validate_positive_int(
115
+ top_n,
116
+ param_name="top_n",
117
+ min_value=1,
118
+ max_value=1000
119
+ )
120
+ if error:
121
+ return error
122
+
123
+ # For local execution, validate path
124
+ if not host:
125
+ try:
126
+ path_obj = Path(path).resolve(strict=True)
127
+ except (OSError, RuntimeError):
128
+ return f"Error: Path does not exist or cannot be resolved: {path}"
129
+
130
+ if not path_obj.is_dir():
131
+ return f"Error: Path is not a directory: {path}"
132
+
133
+ if not os.access(path_obj, os.R_OK):
134
+ return f"Error: Permission denied to read directory: {path}"
135
+
136
+ path_str = str(path_obj)
137
+ else:
138
+ # For remote execution, use the path as-is
139
+ path_str = path
140
+
141
+ # Use du command to get directory sizes efficiently
142
+ returncode, stdout, _ = await execute_command(
143
+ ["du", "-b", "--max-depth=1", path_str],
144
+ host=host,
145
+ username=username
146
+ )
147
+
148
+ # Parse output - du may return non-zero on permission errors but still give valid data
149
+ lines = stdout.strip().split('\n')
150
+ dir_sizes = []
151
+
152
+ for line in lines:
153
+ if not line:
154
+ continue
155
+ parts = line.split('\t', 1)
156
+ if len(parts) == 2:
157
+ try:
158
+ size = int(parts[0])
159
+ dir_path_str = parts[1]
160
+ # Skip the parent directory itself
161
+ dir_name = Path(dir_path_str).name
162
+ if dir_path_str != path_str:
163
+ dir_sizes.append((dir_name, size))
164
+ except (ValueError, IndexError):
165
+ continue
166
+
167
+ if not dir_sizes:
168
+ # Only error if we got no output AND a bad return code
169
+ if returncode != 0:
170
+ return f"Error: du command failed and returned no directory data"
171
+ return f"No subdirectories found in: {path}"
172
+
173
+ # Sort by size (descending) and take top N
174
+ dir_sizes.sort(key=lambda x: x[1], reverse=True)
175
+ top_dirs = dir_sizes[:top_n]
176
+
177
+ # Format output
178
+ result = []
179
+ result.append(f"=== Top {len(top_dirs)} Largest Directories ===")
180
+ result.append(f"Path: {path_str}")
181
+ result.append(f"\nTotal subdirectories found: {len(dir_sizes)}\n")
182
+
183
+ for i, (dir_name, size) in enumerate(top_dirs, 1):
184
+ result.append(f"{i}. {dir_name}")
185
+ result.append(f" Size: {format_bytes(size)}")
186
+
187
+ return "\n".join(result)
188
+
189
+ except Exception as e:
190
+ return f"Error analyzing directories: {str(e)}"
191
+
192
+
193
+ async def list_directories_by_name(
194
+ path: str,
195
+ reverse: bool = False,
196
+ host: Optional[str] = None,
197
+ username: Optional[str] = None
198
+ ) -> str:
199
+ """
200
+ List directories under a specified path sorted by name.
201
+
202
+ This function uses efficient Linux primitives (find and sort) to list directories.
203
+
204
+ Args:
205
+ path: The directory path to analyze
206
+ reverse: If True, sort in reverse alphabetical order (Z-A)
207
+ host: Optional remote host to connect to
208
+ username: Optional SSH username (required if host is provided)
209
+
210
+ Returns:
211
+ Formatted string with directory names, or error message if validation fails
212
+ """
213
+ import os
214
+ from pathlib import Path
215
+
216
+ try:
217
+ # For local execution, validate path
218
+ if not host:
219
+ try:
220
+ path_obj = Path(path).resolve(strict=True)
221
+ except (OSError, RuntimeError):
222
+ return f"Error: Path does not exist or cannot be resolved: {path}"
223
+
224
+ if not path_obj.is_dir():
225
+ return f"Error: Path is not a directory: {path}"
226
+
227
+ if not os.access(path_obj, os.R_OK):
228
+ return f"Error: Permission denied to read directory: {path}"
229
+
230
+ path_str = str(path_obj)
231
+ else:
232
+ # For remote execution, use the path as-is
233
+ path_str = path
234
+
235
+ # Use find to list only immediate subdirectories
236
+ returncode, stdout, _ = await execute_command(
237
+ ["find", path_str, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%f\\n"],
238
+ host=host,
239
+ username=username
240
+ )
241
+
242
+ if returncode != 0:
243
+ return f"Error running find command: command failed with return code {returncode}"
244
+
245
+ # Parse and sort output
246
+ directories = [line for line in stdout.strip().split('\n') if line]
247
+
248
+ if not directories:
249
+ return f"No subdirectories found in: {path}"
250
+
251
+ # Sort alphabetically
252
+ directories.sort(reverse=reverse)
253
+
254
+ # Format output
255
+ result = []
256
+ sort_order = "Reverse Alphabetical" if reverse else "Alphabetical"
257
+ result.append(f"=== Directories ({sort_order}) ===")
258
+ result.append(f"Path: {path_str}")
259
+ result.append(f"\nTotal subdirectories found: {len(directories)}\n")
260
+
261
+ for i, dir_name in enumerate(directories, 1):
262
+ result.append(f"{i}. {dir_name}")
263
+
264
+ return "\n".join(result)
265
+
266
+ except Exception as e:
267
+ return f"Error listing directories: {str(e)}"
268
+
269
+
270
+ async def list_directories_by_modified_date(
271
+ path: str,
272
+ newest_first: bool = True,
273
+ host: Optional[str] = None,
274
+ username: Optional[str] = None
275
+ ) -> str:
276
+ """
277
+ List directories under a specified path sorted by modification date.
278
+
279
+ This function uses efficient Linux primitives (find) to list directories with timestamps.
280
+
281
+ Args:
282
+ path: The directory path to analyze
283
+ newest_first: If True, show newest first; if False, show oldest first
284
+ host: Optional remote host to connect to
285
+ username: Optional SSH username (required if host is provided)
286
+
287
+ Returns:
288
+ Formatted string with directory names and dates, or error message if validation fails
289
+ """
290
+ import os
291
+ from pathlib import Path
292
+ from datetime import datetime
293
+
294
+ try:
295
+ # For local execution, validate path
296
+ if not host:
297
+ try:
298
+ path_obj = Path(path).resolve(strict=True)
299
+ except (OSError, RuntimeError):
300
+ return f"Error: Path does not exist or cannot be resolved: {path}"
301
+
302
+ if not path_obj.is_dir():
303
+ return f"Error: Path is not a directory: {path}"
304
+
305
+ if not os.access(path_obj, os.R_OK):
306
+ return f"Error: Permission denied to read directory: {path}"
307
+
308
+ path_str = str(path_obj)
309
+ else:
310
+ # For remote execution, use the path as-is
311
+ path_str = path
312
+
313
+ # Use find with modification time
314
+ returncode, stdout, _ = await execute_command(
315
+ ["find", path_str, "-mindepth", "1", "-maxdepth", "1", "-type", "d", "-printf", "%T@\\t%f\\n"],
316
+ host=host,
317
+ username=username
318
+ )
319
+
320
+ if returncode != 0:
321
+ return f"Error running find command: command failed with return code {returncode}"
322
+
323
+ # Parse output
324
+ directories = []
325
+ for line in stdout.strip().split('\n'):
326
+ if line:
327
+ parts = line.split('\t', 1)
328
+ if len(parts) == 2:
329
+ try:
330
+ timestamp = float(parts[0])
331
+ dir_name = parts[1]
332
+ directories.append((timestamp, dir_name))
333
+ except ValueError:
334
+ continue
335
+
336
+ if not directories:
337
+ return f"No subdirectories found in: {path}"
338
+
339
+ # Sort by timestamp
340
+ directories.sort(key=lambda x: x[0], reverse=newest_first)
341
+
342
+ # Format output
343
+ result = []
344
+ sort_order = "Newest First" if newest_first else "Oldest First"
345
+ result.append(f"=== Directories ({sort_order}) ===")
346
+ result.append(f"Path: {path_str}")
347
+ result.append(f"\nTotal subdirectories found: {len(directories)}\n")
348
+
349
+ for i, (timestamp, dir_name) in enumerate(directories, 1):
350
+ dt = datetime.fromtimestamp(timestamp)
351
+ result.append(f"{i}. {dir_name}")
352
+ result.append(f" Modified: {dt.strftime('%Y-%m-%d %H:%M:%S')}")
353
+
354
+ return "\n".join(result)
355
+
356
+ except Exception as e:
357
+ return f"Error listing directories: {str(e)}"
358
+