-
Notifications
You must be signed in to change notification settings - Fork 0
Comprehensive codebase review and analysis #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| """Retry utilities for resilient operations.""" | ||
|
|
||
| import logging | ||
|
|
||
| import structlog | ||
| from tenacity import ( | ||
| before_sleep_log, | ||
| retry, | ||
| retry_if_exception_type, | ||
| stop_after_attempt, | ||
| wait_exponential, | ||
| ) | ||
|
|
||
| from docker_mcp.core.exceptions import DockerCommandError, DockerContextError | ||
|
|
||
| logger = structlog.get_logger(__name__) | ||
|
|
||
| # SSH retry configuration | ||
| retry_ssh_operation = retry( | ||
| stop=stop_after_attempt(3), | ||
| wait=wait_exponential(multiplier=1, min=2, max=30), | ||
| retry=retry_if_exception_type((ConnectionError, TimeoutError, OSError)), | ||
| before_sleep=before_sleep_log(logger, logging.INFO), | ||
| reraise=True, | ||
| ) | ||
|
|
||
| # Docker API retry configuration | ||
| retry_docker_operation = retry( | ||
| stop=stop_after_attempt(3), | ||
| wait=wait_exponential(multiplier=1, min=1, max=10), | ||
| retry=retry_if_exception_type(( | ||
| ConnectionError, | ||
| TimeoutError, | ||
| DockerCommandError, | ||
| DockerContextError, | ||
| )), | ||
| before_sleep=before_sleep_log(logger, logging.INFO), | ||
| reraise=True, | ||
| ) | ||
|
|
||
| # Network operation retry configuration | ||
| retry_network_operation = retry( | ||
| stop=stop_after_attempt(4), | ||
| wait=wait_exponential(multiplier=1, min=2, max=30), | ||
| retry=retry_if_exception_type((ConnectionError, TimeoutError)), | ||
| before_sleep=before_sleep_log(logger, logging.INFO), | ||
| reraise=True, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| """SSH utilities for secure connection management.""" | ||
| import asyncio | ||
| import shlex | ||
| from pathlib import Path | ||
|
|
||
| from docker_mcp.core.config_loader import DockerHost | ||
|
|
||
|
|
||
| def get_ssh_config_dir() -> Path: | ||
| """Get SSH configuration directory for docker-mcp.""" | ||
| config_dir = Path.home() / ".config" / "docker-mcp" / "ssh" | ||
| config_dir.mkdir(parents=True, exist_ok=True) | ||
| return config_dir | ||
|
|
||
|
|
||
| def get_known_hosts_file() -> Path: | ||
| """Get path to docker-mcp known_hosts file.""" | ||
| return get_ssh_config_dir() / "known_hosts" | ||
|
|
||
|
|
||
| def build_ssh_command( | ||
| host: DockerHost, | ||
| remote_command: list[str] | None = None, | ||
| strict_host_checking: bool = True | ||
| ) -> list[str]: | ||
| """Build secure SSH command with proper options. | ||
|
|
||
| Args: | ||
| host: Docker host configuration | ||
| remote_command: Optional remote command to execute | ||
| strict_host_checking: Enable strict host key checking (default: True) | ||
|
|
||
| Returns: | ||
| Complete SSH command as list | ||
|
|
||
| Example: | ||
| >>> host = DockerHost(hostname="server.com", user="docker", port=22) | ||
| >>> build_ssh_command(host) | ||
| ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', | ||
| '-o', 'StrictHostKeyChecking=accept-new', '[email protected]'] | ||
| """ | ||
| # Get known_hosts file | ||
| known_hosts = get_known_hosts_file() | ||
|
|
||
| # Build SSH command | ||
| ssh_cmd = [ | ||
| "ssh", | ||
| "-o", "BatchMode=yes", | ||
| "-o", "ConnectTimeout=10", | ||
| "-o", "ServerAliveInterval=30", | ||
| "-o", "ServerAliveCountMax=3", | ||
| "-o", "LogLevel=ERROR", | ||
| ] | ||
|
|
||
| # Host key checking configuration | ||
| if strict_host_checking: | ||
| # Accept new host keys but verify existing ones | ||
| ssh_cmd.extend([ | ||
| "-o", "StrictHostKeyChecking=accept-new", | ||
| "-o", f"UserKnownHostsFile={known_hosts}", | ||
| "-o", "HashKnownHosts=yes", | ||
| ]) | ||
| else: | ||
| # Legacy mode - log warning | ||
| ssh_cmd.extend([ | ||
| "-o", "StrictHostKeyChecking=no", | ||
| "-o", "UserKnownHostsFile=/dev/null", | ||
| ]) | ||
|
|
||
| # Add identity file if specified | ||
| if host.identity_file: | ||
| ssh_cmd.extend(["-i", host.identity_file]) | ||
|
|
||
| # Add port if non-standard | ||
| if host.port and host.port != 22: | ||
| ssh_cmd.extend(["-p", str(host.port)]) | ||
|
|
||
| # Handle hostname with proper quoting and IPv6 support | ||
| hostname = host.hostname | ||
| if ":" in hostname and not (hostname.startswith("[") and hostname.endswith("]")): | ||
| # IPv6 address needs brackets | ||
| hostname = f"[{hostname}]" | ||
|
|
||
| # Use proper quoting for hostname | ||
| user_host = f"{host.user}@{shlex.quote(hostname)}" | ||
| ssh_cmd.append(user_host) | ||
|
|
||
| # Add remote command if provided | ||
| if remote_command: | ||
| ssh_cmd.extend(remote_command) | ||
|
|
||
| return ssh_cmd | ||
|
|
||
|
|
||
| async def test_ssh_connection(host: DockerHost) -> tuple[bool, str]: | ||
| """Test SSH connection to host. | ||
|
|
||
| Args: | ||
| host: Docker host to test | ||
|
|
||
| Returns: | ||
| Tuple of (success: bool, message: str) | ||
| """ | ||
| # Use strict host checking from host config or default to True | ||
| strict = getattr(host, 'strict_host_checking', True) | ||
| ssh_cmd = build_ssh_command(host, ["echo", "OK"], strict_host_checking=strict) | ||
|
|
||
| try: | ||
| process = await asyncio.create_subprocess_exec( | ||
| *ssh_cmd, | ||
| stdout=asyncio.subprocess.PIPE, | ||
| stderr=asyncio.subprocess.PIPE | ||
| ) | ||
| stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0) | ||
|
|
||
| if process.returncode == 0 and b"OK" in stdout: | ||
| return True, "SSH connection successful" | ||
| else: | ||
| return False, f"SSH connection failed: {stderr.decode()}" | ||
|
|
||
| except asyncio.TimeoutError: | ||
| return False, "SSH connection timeout" | ||
| except Exception as e: | ||
| return False, f"SSH connection error: {str(e)}" | ||
|
Comment on lines
+121
to
+124
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Improve exception handling specificity. The code uses Apply this diff: - except asyncio.TimeoutError:
+ except TimeoutError:
return False, "SSH connection timeout"
- except Exception as e:
- return False, f"SSH connection error: {str(e)}"
+ except (OSError, asyncio.CancelledError) as e:
+ return False, f"SSH connection error: {e!s}"Note: 🧰 Tools🪛 Ruff (0.14.3)121-121: Replace aliased errors with Replace (UP041) 123-123: Do not catch blind exception: (BLE001) 124-124: Use explicit conversion flag Replace with conversion flag (RUF010) 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,8 +12,10 @@ | |
| Total impact: ~120 lines of duplicate code eliminated. | ||
| """ | ||
|
|
||
| from .constants import SSH_NO_HOST_CHECK | ||
| import warnings | ||
|
|
||
| from .core.config_loader import DockerHost, DockerMCPConfig | ||
| from .core.ssh_utils import build_ssh_command as _build_ssh_command_secure | ||
|
|
||
|
|
||
| def build_ssh_command(host: DockerHost) -> list[str]: | ||
|
|
@@ -36,37 +38,27 @@ def build_ssh_command(host: DockerHost) -> list[str]: | |
| Example: | ||
| >>> host = DockerHost(hostname="server.com", user="docker", port=22) | ||
| >>> build_ssh_command(host) | ||
| ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=10', '[email protected]'] | ||
| ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', | ||
| '-o', 'StrictHostKeyChecking=accept-new', '[email protected]'] | ||
|
|
||
| Note: | ||
| This function now uses secure SSH host key verification by default. | ||
| To disable (not recommended), set host.strict_host_checking = False. | ||
| """ | ||
| import shlex | ||
|
|
||
| ssh_cmd = [ | ||
| "ssh", | ||
| "-o", SSH_NO_HOST_CHECK, | ||
| "-o", "UserKnownHostsFile=/dev/null", # Prevent host key issues | ||
| "-o", "LogLevel=ERROR", # Reduce noise | ||
| "-o", "ConnectTimeout=10", # Connection timeout for automation | ||
| "-o", "ServerAliveInterval=30", # Keep connection alive | ||
| "-o", "BatchMode=yes", # Fully automated connections (no prompts) | ||
| ] | ||
|
|
||
| if host.identity_file: | ||
| ssh_cmd.extend(["-i", host.identity_file]) | ||
|
|
||
| if host.port != 22: | ||
| ssh_cmd.extend(["-p", str(host.port)]) | ||
|
|
||
| # Handle hostname with proper quoting and IPv6 support | ||
| hostname = host.hostname | ||
| if ":" in hostname and not (hostname.startswith("[") and hostname.endswith("]")): | ||
| # IPv6 address needs brackets | ||
| hostname = f"[{hostname}]" | ||
|
|
||
| # Use proper quoting for hostname | ||
| user_host = f"{host.user}@{shlex.quote(hostname)}" | ||
| ssh_cmd.append(user_host) | ||
|
|
||
| return ssh_cmd | ||
| # Get strict_host_checking from host config (defaults to True for security) | ||
| strict = getattr(host, 'strict_host_checking', True) | ||
|
|
||
| # Issue deprecation warning if using insecure mode | ||
| if not strict: | ||
| warnings.warn( | ||
| "SSH host key verification is disabled. This is insecure and " | ||
| "vulnerable to MITM attacks. Enable strict_host_checking for security.", | ||
| DeprecationWarning, | ||
| stacklevel=2 | ||
| ) | ||
|
|
||
| # Use the new secure SSH command builder | ||
| return _build_ssh_command_secure(host, strict_host_checking=strict) | ||
|
|
||
|
|
||
| def validate_host(config: DockerMCPConfig, host_id: str) -> tuple[bool, str]: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The retry decorators pass a
structlogBoundLoggertotenacity.before_sleep_log. Tenacity invokeslogger.log(...)on the supplied logger, butBoundLoggerhas nologmethod, so the first retry attempt will raiseAttributeErrorand abort the retry instead of sleeping and retrying. Use a standardlogging.getLoggeror a custom callback that callslogger.info/logger.warningto avoid breaking retries.Useful? React with 👍 / 👎.