server-management-lib 0.2.0__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,447 @@
1
+ """
2
+ SSH Connection Manager for Remote Server Operations
3
+
4
+ Handles SSH connections with strict security:
5
+
6
+ - Host keys are verified by default against ``ssh.known_hosts``.
7
+ - Key/agent authentication is used when no password is configured.
8
+ - Commands are validated before execution.
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ import os
14
+ import re
15
+ import shlex
16
+ import stat
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import asyncssh
21
+
22
+ from .base import (
23
+ ExecutorBase,
24
+ RemoteDirEntry,
25
+ RemoteFileError,
26
+ RemotePathError,
27
+ is_within,
28
+ )
29
+ from .output import sanitize_output
30
+ from .security import SecurityValidator
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ DEFAULT_KNOWN_HOSTS = "~/.ssh/known_hosts"
35
+ DISABLED_KNOWN_HOSTS = {"", "none", "false", "off"}
36
+ # Bound every SFTP metadata/read operation so a FIFO, stuck server or dropped
37
+ # connection cannot block a tool call forever.
38
+ SFTP_OPERATION_TIMEOUT = 30
39
+
40
+
41
+ async def _sftp_operation(awaitable, *, description: str):
42
+ """Await an SFTP operation with a timeout and a readable error."""
43
+ try:
44
+ return await asyncio.wait_for(awaitable, timeout=SFTP_OPERATION_TIMEOUT)
45
+ except TimeoutError as e:
46
+ raise RemoteFileError(
47
+ f"SFTP {description} timed out after {SFTP_OPERATION_TIMEOUT}s"
48
+ ) from e
49
+
50
+
51
+ class SSHConfigurationError(RuntimeError):
52
+ """Raised when SSH settings cannot be turned into a safe connection."""
53
+
54
+
55
+ def _load_known_hosts(path: Path) -> str | bytes:
56
+ """Load a known_hosts file, skipping entries asyncssh/cryptography cannot parse.
57
+
58
+ Modern OpenSSL/cryptography rejects legacy DSA keys, and a single such line
59
+ makes ``read_known_hosts()`` fail for the whole file. We keep verification
60
+ enabled for every other entry instead of disabling it altogether.
61
+
62
+ Returns the path when the file parses cleanly, otherwise a sanitized
63
+ known_hosts payload (bytes) with unreadable lines removed.
64
+ """
65
+ try:
66
+ asyncssh.read_known_hosts(str(path))
67
+ return str(path)
68
+ except Exception as e:
69
+ logger.warning(
70
+ "Could not parse %s as a whole (%s); skipping unreadable entries",
71
+ path,
72
+ e,
73
+ )
74
+
75
+ kept: list[str] = []
76
+ skipped_lines: list[int] = []
77
+ for lineno, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
78
+ entry = line.strip()
79
+ if not entry or entry.startswith("#"):
80
+ continue
81
+ try:
82
+ asyncssh.import_known_hosts(entry)
83
+ except Exception:
84
+ skipped_lines.append(lineno)
85
+ continue
86
+ kept.append(entry)
87
+
88
+ if skipped_lines:
89
+ # One aggregated warning per file keeps the log readable while still
90
+ # making the skipped entries identifiable.
91
+ preview = ", ".join(str(line) for line in skipped_lines[:10])
92
+ suffix = ", ..." if len(skipped_lines) > 10 else ""
93
+ logger.warning(
94
+ "Skipped %d unsupported known_hosts entr%s in %s (lines %s%s); "
95
+ "host key verification stays enabled for the remaining entries",
96
+ len(skipped_lines),
97
+ "y" if len(skipped_lines) == 1 else "ies",
98
+ path,
99
+ preview,
100
+ suffix,
101
+ )
102
+ if not kept:
103
+ raise SSHConfigurationError(
104
+ f"No usable host keys in {path}. Remove legacy entries or regenerate "
105
+ "the file with ssh-keyscan."
106
+ )
107
+ logger.info(
108
+ "Loaded %d known_hosts entries from %s (%d skipped)",
109
+ len(kept),
110
+ path,
111
+ len(skipped_lines),
112
+ )
113
+ return ("\n".join(kept) + "\n").encode()
114
+
115
+
116
+ class SSHManager(ExecutorBase):
117
+ """Manages SSH connections with strict security controls."""
118
+
119
+ def __init__(self, config: dict, security: SecurityValidator):
120
+ """
121
+ Initialize SSH manager.
122
+
123
+ Args:
124
+ config: Configuration dictionary with SSH settings
125
+ security: Security validator instance
126
+ """
127
+ super().__init__(config, security)
128
+ self.ssh_config = config.get("ssh", {}) or {}
129
+ self.connection: asyncssh.SSHClientConnection | None = None
130
+ self._connected = False
131
+ self._connect_lock = asyncio.Lock()
132
+ self._known_hosts_cache: str | bytes | None = None
133
+ self._known_hosts_loaded = False
134
+
135
+ # ------------------------------------------------------------------
136
+ # Connection setup
137
+ # ------------------------------------------------------------------
138
+
139
+ def _known_hosts_option(self) -> str | bytes | None:
140
+ """Return the known_hosts path, sanitized payload, or ``None``."""
141
+ if self._known_hosts_loaded:
142
+ return self._known_hosts_cache
143
+
144
+ value = self._load_known_hosts_option()
145
+ self._known_hosts_cache = value
146
+ self._known_hosts_loaded = True
147
+ return value
148
+
149
+ def _load_known_hosts_option(self) -> str | bytes | None:
150
+ value = self.ssh_config.get("known_hosts", DEFAULT_KNOWN_HOSTS)
151
+ if value is None or (
152
+ isinstance(value, str) and value.strip().lower() in DISABLED_KNOWN_HOSTS
153
+ ):
154
+ logger.warning(
155
+ "SSH host key verification is DISABLED. This is insecure; "
156
+ "set ssh.known_hosts to a known_hosts file to enable it."
157
+ )
158
+ return None
159
+
160
+ path = Path(str(value)).expanduser()
161
+ if not path.exists():
162
+ raise SSHConfigurationError(
163
+ f"SSH known_hosts file not found: {path}. Add the host key first "
164
+ f"(ssh-keyscan -H <host> >> {path}) or set ssh.known_hosts to "
165
+ "'none' to disable verification (insecure)."
166
+ )
167
+ return _load_known_hosts(path)
168
+
169
+ def build_connect_kwargs(self) -> dict:
170
+ """Build keyword arguments for :func:`asyncssh.connect`."""
171
+ kwargs: dict = {"host": self.ssh_config.get("host") or "localhost"}
172
+
173
+ kwargs["port"] = int(self.ssh_config.get("port") or 22)
174
+
175
+ username = self.ssh_config.get("username")
176
+ if username:
177
+ kwargs["username"] = username
178
+
179
+ key_path = self.ssh_config.get("key_path")
180
+ if key_path:
181
+ expanded_path = os.path.expanduser(str(key_path))
182
+ kwargs["client_keys"] = [expanded_path]
183
+ logger.info("Using SSH key: %s", expanded_path)
184
+
185
+ password = self.ssh_config.get("password")
186
+ if password:
187
+ kwargs["password"] = password
188
+
189
+ connect_timeout = self.ssh_config.get("connect_timeout")
190
+ if connect_timeout:
191
+ kwargs["connect_timeout"] = int(connect_timeout)
192
+
193
+ # Explicitly pass known_hosts so that a missing file cannot silently
194
+ # degrade to "no verification" (asyncssh does that by default).
195
+ kwargs["known_hosts"] = self._known_hosts_option()
196
+
197
+ return kwargs
198
+
199
+ async def connect(self) -> None:
200
+ """Establish SSH connection to the remote server.
201
+
202
+ Concurrent tool calls share one connection. Without the lock they can
203
+ all observe ``_connected == False`` and open overlapping connections,
204
+ leaking every connection but the last one. The second check inside the
205
+ lock makes the call idempotent: a caller that waited for an in-flight
206
+ connect reuses the connection instead of opening another.
207
+ """
208
+ async with self._connect_lock:
209
+ if self._connected and self.connection is not None:
210
+ return
211
+
212
+ kwargs = self.build_connect_kwargs()
213
+ host = kwargs["host"]
214
+
215
+ try:
216
+ self.connection = await asyncssh.connect(**kwargs)
217
+ self._connected = True
218
+ logger.info(
219
+ "Connected to %s:%s as %s",
220
+ host,
221
+ kwargs.get("port", 22),
222
+ kwargs.get("username", "(default)"),
223
+ )
224
+ except Exception as e:
225
+ self._connected = False
226
+ self.connection = None
227
+ logger.error("Failed to connect to %s: %s", host, e)
228
+ raise
229
+
230
+ async def disconnect(self) -> None:
231
+ """Close SSH connection."""
232
+ connection = self.connection
233
+ self.connection = None
234
+ self._connected = False
235
+ if connection:
236
+ connection.close()
237
+ wait_closed = getattr(connection, "wait_closed", None)
238
+ if wait_closed is not None:
239
+ try:
240
+ await wait_closed()
241
+ except Exception as e: # pragma: no cover - best effort
242
+ logger.debug("Error while closing SSH connection: %s", e)
243
+ logger.info("SSH connection closed")
244
+
245
+ async def execute_safe_command(self, command: str, timeout: int = 30) -> str:
246
+ """
247
+ Execute a pre-validated safe command.
248
+
249
+ Args:
250
+ command: Pre-validated command (constructed by application code)
251
+ timeout: Timeout in seconds (default: 30)
252
+
253
+ Returns:
254
+ Command output
255
+ """
256
+ if not self.security.is_command_safe(command):
257
+ logger.error("Attempted to execute unsafe command: %s", command)
258
+ self.audit.log("command_blocked", command=command)
259
+ return "❌ Security violation: Command blocked by security policy"
260
+
261
+ if not self._connected or not self.connection:
262
+ await self.connect()
263
+
264
+ if self.connection is None:
265
+ return "❌ Connection failed"
266
+
267
+ started = time.monotonic()
268
+ try:
269
+ result = await self.connection.run(
270
+ command,
271
+ check=False,
272
+ timeout=timeout,
273
+ )
274
+
275
+ output: str = ""
276
+ if result.stdout:
277
+ output += str(result.stdout)
278
+ if result.stderr:
279
+ if output:
280
+ output += "\n--- STDERR ---\n"
281
+ output += str(result.stderr)
282
+
283
+ if result.exit_status != 0:
284
+ output += f"\n\n⚠️ Exit code: {result.exit_status}"
285
+
286
+ sanitized = sanitize_output(
287
+ output, self.max_output_bytes, label="command output"
288
+ )
289
+ self.audit.log(
290
+ "command",
291
+ command=command,
292
+ exit_status=result.exit_status,
293
+ duration_ms=round((time.monotonic() - started) * 1000, 1),
294
+ output_bytes=len(output.encode("utf-8")),
295
+ truncated=sanitized != output,
296
+ )
297
+ return sanitized
298
+ except (asyncssh.ConnectionLost, asyncssh.DisconnectError) as e:
299
+ # Drop the dead connection so the next call reconnects.
300
+ self.connection = None
301
+ self._connected = False
302
+ self.audit.log("command_connection_lost", command=command, error=str(e))
303
+ return f"❌ SSH connection lost: {e}"
304
+ except asyncssh.Error as e:
305
+ self.audit.log("command_error", command=command, error=str(e))
306
+ return f"❌ SSH error: {e}"
307
+ except TimeoutError:
308
+ self.audit.log("command_timeout", command=command, timeout=timeout)
309
+ return f"❌ Command timed out after {timeout}s"
310
+ except Exception as e:
311
+ self.audit.log("command_error", command=command, error=str(e))
312
+ return f"❌ Error executing command: {e}"
313
+
314
+ # ------------------------------------------------------------------
315
+ # Remote files (SFTP; no shell involved)
316
+ # ------------------------------------------------------------------
317
+
318
+ async def _get_sftp(self) -> asyncssh.SFTPClient:
319
+ if not self._connected or not self.connection:
320
+ await self.connect()
321
+ if self.connection is None:
322
+ raise RemoteFileError("SSH connection failed")
323
+ return await self.connection.start_sftp_client()
324
+
325
+ async def read_remote_file(
326
+ self,
327
+ path: str,
328
+ *,
329
+ allowed_base: str | None = None,
330
+ max_bytes: int | None = None,
331
+ ) -> str:
332
+ """Read a remote file over SFTP with an optional base-path check."""
333
+ limit = max_bytes or self.max_output_bytes
334
+ async with await self._get_sftp() as sftp:
335
+ real_path = str(
336
+ await _sftp_operation(sftp.realpath(path), description="realpath")
337
+ )
338
+ real_base = (
339
+ str(
340
+ await _sftp_operation(
341
+ sftp.realpath(allowed_base), description="realpath"
342
+ )
343
+ )
344
+ if allowed_base is not None
345
+ else None
346
+ )
347
+ if real_base is not None and not is_within(real_path, real_base):
348
+ self.audit.log("file_read_blocked", path=path, resolved=real_path)
349
+ raise RemotePathError(
350
+ f"Remote path escapes the allowed directory: {real_path}"
351
+ )
352
+
353
+ attrs = await _sftp_operation(sftp.stat(real_path), description="stat")
354
+ if not stat.S_ISREG(attrs.permissions or 0):
355
+ self.audit.log(
356
+ "file_read_blocked",
357
+ path=path,
358
+ resolved=real_path,
359
+ reason="not_regular_file",
360
+ )
361
+ raise RemoteFileError(f"Remote path is not a regular file: {real_path}")
362
+ size = attrs.size or 0
363
+ async with sftp.open(real_path, "rb") as handle:
364
+ data = await _sftp_operation(handle.read(limit + 1), description="read")
365
+
366
+ text = data.decode("utf-8", errors="replace")
367
+ sanitized = sanitize_output(text, limit, label="file contents")
368
+ self.audit.log(
369
+ "file_read",
370
+ path=real_path,
371
+ size=size,
372
+ truncated=len(data) > limit or sanitized != text,
373
+ )
374
+ return sanitized
375
+
376
+ async def list_remote_dir(
377
+ self,
378
+ path: str,
379
+ *,
380
+ allowed_base: str | None = None,
381
+ ) -> list[RemoteDirEntry]:
382
+ """List a remote directory over SFTP with an optional base-path check."""
383
+ async with await self._get_sftp() as sftp:
384
+ real_path = str(
385
+ await _sftp_operation(sftp.realpath(path), description="realpath")
386
+ )
387
+ real_base = (
388
+ str(
389
+ await _sftp_operation(
390
+ sftp.realpath(allowed_base), description="realpath"
391
+ )
392
+ )
393
+ if allowed_base is not None
394
+ else None
395
+ )
396
+ if real_base is not None and not is_within(real_path, real_base):
397
+ self.audit.log("dir_list_blocked", path=path, resolved=real_path)
398
+ raise RemotePathError(
399
+ f"Remote path escapes the allowed directory: {real_path}"
400
+ )
401
+ names = await _sftp_operation(
402
+ sftp.readdir(real_path), description="readdir"
403
+ )
404
+
405
+ entries = [
406
+ RemoteDirEntry(
407
+ name=entry.filename,
408
+ longname=entry.longname,
409
+ size=entry.attrs.size or 0,
410
+ )
411
+ for entry in names
412
+ if entry.filename not in {".", ".."}
413
+ ]
414
+ entries.sort(key=lambda entry: entry.name)
415
+ self.audit.log("dir_list", path=real_path, entries=len(entries))
416
+ return entries
417
+
418
+ async def check_service_exists(self, service: str) -> bool:
419
+ """
420
+ Check if a service directory exists under ``security.services_path``.
421
+
422
+ Args:
423
+ service: Service name (should be validated already)
424
+
425
+ Returns:
426
+ True if service exists
427
+ """
428
+ try:
429
+ service_path = f"{self.services_base_path.rstrip('/')}/{service}"
430
+ cmd = f"test -d {shlex.quote(service_path)}"
431
+ result = await self.execute_safe_command(cmd)
432
+ if "❌" in result:
433
+ return False
434
+
435
+ match = re.search(r"⚠️ Exit code:\s*(-?\d+)", result)
436
+ return match is None or int(match.group(1)) == 0
437
+ except Exception:
438
+ return False
439
+
440
+ async def __aenter__(self):
441
+ """Async context manager entry."""
442
+ await self.connect()
443
+ return self
444
+
445
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
446
+ """Async context manager exit."""
447
+ await self.disconnect()
@@ -0,0 +1,128 @@
1
+ """Streamable HTTP transport helpers for the MCP servers.
2
+
3
+ The default transport stays stdio. When an operator opts into Streamable HTTP,
4
+ this module enforces two rules:
5
+
6
+ - a non-loopback bind address requires a bearer token;
7
+ - when a token is configured, every HTTP request must present it.
8
+
9
+ The middleware is a plain ASGI application, so the library does not depend on
10
+ Starlette. Uvicorn is imported lazily where it is actually used (it is already
11
+ a dependency of the ``mcp`` package that the servers install).
12
+ """
13
+
14
+ import hmac
15
+ import logging
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
21
+ DEFAULT_HTTP_PATH = "/mcp"
22
+ DEFAULT_HTTP_PORT = 8000
23
+
24
+
25
+ def is_loopback_host(host: str) -> bool:
26
+ """Return True when ``host`` is a loopback bind address."""
27
+ return host in LOOPBACK_HOSTS
28
+
29
+
30
+ def validate_http_binding(host: str, token: str | None) -> None:
31
+ """Refuse a non-loopback Streamable HTTP bind without a token.
32
+
33
+ Raises:
34
+ ValueError: The binding would expose the tools unauthenticated.
35
+ """
36
+ if is_loopback_host(host) or token:
37
+ return
38
+ raise ValueError(
39
+ f"Refusing to bind Streamable HTTP to '{host}' without an access token. "
40
+ "Use a loopback host (127.0.0.1/::1/localhost) or configure a token "
41
+ "(transport.token, MCP_HTTP_TOKEN or --http-token-env)."
42
+ )
43
+
44
+
45
+ class BearerTokenMiddleware:
46
+ """Reject HTTP requests that do not carry the configured bearer token."""
47
+
48
+ def __init__(self, app: Any, *, token: str, header: str = "authorization"):
49
+ if not token:
50
+ raise ValueError("A non-empty token is required")
51
+ self.app = app
52
+ self.token = token
53
+ self.header = header.lower().encode("latin-1")
54
+ self._token = token.encode("utf-8")
55
+
56
+ async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
57
+ if scope.get("type") != "http":
58
+ await self.app(scope, receive, send)
59
+ return
60
+
61
+ provided = b""
62
+ for name, value in scope.get("headers") or []:
63
+ if name.lower() == self.header:
64
+ provided = value
65
+ break
66
+
67
+ # RFC 9110 auth schemes are case-insensitive: ``bearer`` and ``Bearer``
68
+ # are equivalent. The credential itself is compared in constant time.
69
+ scheme = b""
70
+ credential = b""
71
+ if provided:
72
+ parts = provided.split(None, 1)
73
+ if len(parts) == 2:
74
+ scheme, credential = parts
75
+
76
+ if scheme.lower() != b"bearer" or not hmac.compare_digest(
77
+ credential, self._token
78
+ ):
79
+ logger.warning("Rejected unauthenticated Streamable HTTP request")
80
+ body = b'{"error":"unauthorized"}'
81
+ await send(
82
+ {
83
+ "type": "http.response.start",
84
+ "status": 401,
85
+ "headers": [
86
+ (b"content-type", b"application/json"),
87
+ (b"content-length", str(len(body)).encode()),
88
+ (b"www-authenticate", b"Bearer"),
89
+ ],
90
+ }
91
+ )
92
+ await send({"type": "http.response.body", "body": body})
93
+ return
94
+
95
+ await self.app(scope, receive, send)
96
+
97
+
98
+ async def run_streamable_http(
99
+ server: Any,
100
+ *,
101
+ host: str = "127.0.0.1",
102
+ port: int = DEFAULT_HTTP_PORT,
103
+ token: str | None = None,
104
+ path: str = DEFAULT_HTTP_PATH,
105
+ json_response: bool = False,
106
+ ) -> None:
107
+ """Serve an MCPServer over Streamable HTTP with the safety rules above."""
108
+ validate_http_binding(host, token)
109
+
110
+ import uvicorn
111
+
112
+ app = server.streamable_http_app(
113
+ streamable_http_path=path,
114
+ json_response=json_response,
115
+ host=host,
116
+ )
117
+ if token:
118
+ app = BearerTokenMiddleware(app, token=token)
119
+
120
+ config = uvicorn.Config(app, host=host, port=port, log_level="info")
121
+ logger.info(
122
+ "Serving Streamable HTTP on %s:%d%s (bearer token: %s)",
123
+ host,
124
+ port,
125
+ path,
126
+ "required" if token else "not set, loopback only",
127
+ )
128
+ await uvicorn.Server(config).serve()