diff --git a/CHANGES/982.feature b/CHANGES/982.feature new file mode 100644 index 00000000..1b031260 --- /dev/null +++ b/CHANGES/982.feature @@ -0,0 +1 @@ +Add comprehensive SSH support for secure connections to remote Docker hosts via ssh:// URLs with mandatory host key verification and complete documentation. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 1dca99a5..7cba26aa 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -7,6 +7,7 @@ Byeongjun Park Cecil Tonglet Christian Barra Danny Song +Darwin Monroy Edgar Ramírez Mondragón eevelweezel Gaopeiliang diff --git a/aiodocker/docker.py b/aiodocker/docker.py index 74f726c4..f8fc76ee 100644 --- a/aiodocker/docker.py +++ b/aiodocker/docker.py @@ -130,6 +130,8 @@ def __init__( UNIX_PRE_LEN = len(UNIX_PRE) WIN_PRE = "npipe://" WIN_PRE_LEN = len(WIN_PRE) + SSH_PRE = "ssh://" + if _rx_tcp_schemes.search(docker_host): if ( os.environ.get("DOCKER_TLS_VERIFY", "0") == "1" @@ -149,6 +151,13 @@ def __init__( ) # dummy hostname for URL composition self.docker_host = WIN_PRE + "localhost" + elif docker_host.startswith(SSH_PRE): + from .ssh import SSHConnector, parse_ssh_url + + ssh_url, socket_path = parse_ssh_url(docker_host) + connector = SSHConnector(ssh_url, socket_path) + # dummy hostname for URL composition + self.docker_host = "unix://localhost" else: raise ValueError("Missing protocol scheme in docker_host.") self.connector = connector diff --git a/aiodocker/ssh.py b/aiodocker/ssh.py new file mode 100644 index 00000000..31da2ab6 --- /dev/null +++ b/aiodocker/ssh.py @@ -0,0 +1,305 @@ +"""SSH connector for aiodocker.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +import tempfile +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlparse + +import aiohttp +from aiohttp.connector import Connection + + +try: + import asyncssh +except ImportError: + asyncssh = None # type: ignore + +# Try to import SSH config parser (preferably paramiko like docker-py) +try: + from paramiko import SSHConfig +except ImportError: + SSHConfig = None # type: ignore + +log = logging.getLogger(__name__) + +# Constants +DEFAULT_SSH_PORT = 22 +DEFAULT_DOCKER_SOCKET = "/var/run/docker.sock" +DANGEROUS_ENV_VARS = ["LD_LIBRARY_PATH", "SSL_CERT_FILE", "SSL_CERT_DIR", "PYTHONPATH"] + +__all__ = ["SSHConnector", "parse_ssh_url"] + + +class SSHConnector(aiohttp.UnixConnector): + """SSH tunnel connector that forwards Docker socket connections over SSH.""" + + def __init__( + self, + ssh_url: str, + socket_path: str = DEFAULT_DOCKER_SOCKET, + strict_host_keys: bool = True, + **kwargs: Any, + ): + """Initialize SSH connector. + + Args: + ssh_url: SSH connection URL (ssh://user@host:port) + socket_path: Remote Docker socket path + strict_host_keys: Enforce strict host key verification (default: True) + **kwargs: Additional SSH connection options + """ + if asyncssh is None: + raise ImportError( + "asyncssh is required for SSH connections. " + "Install with: pip install aiodocker[ssh]" + ) + + # Validate and parse SSH URL + parsed = urlparse(ssh_url) + if parsed.scheme != "ssh": + raise ValueError(f"Invalid SSH URL scheme: {parsed.scheme}") + + if not parsed.hostname: + raise ValueError("SSH URL must include hostname") + + if not parsed.username: + raise ValueError("SSH URL must include username") + + self._ssh_host = parsed.hostname + self._ssh_port = parsed.port or DEFAULT_SSH_PORT + self._ssh_username = parsed.username + self._ssh_password = parsed.password + self._socket_path = socket_path + self._strict_host_keys = strict_host_keys + + # Load SSH config and merge with provided options + ssh_config = self._load_ssh_config() + self._ssh_options = {**ssh_config, **kwargs} + + # Validate and enforce host key verification + self._setup_host_key_verification() + + # Warn about password in URL + if self._ssh_password: + log.warning( + "Password provided in SSH URL. Consider using SSH key authentication " + "for better security. Passwords may be exposed in logs or memory dumps." + ) + + # Connection state + self._ssh_conn: Optional[asyncssh.SSHClientConnection] = None + self._ssh_context: Optional[Any] = None + self._tunnel_lock = asyncio.Lock() + + # Create secure temporary directory (system chooses location and sets permissions) + self._temp_dir = tempfile.TemporaryDirectory() + self._local_socket_path = os.path.join(self._temp_dir.name, "docker.sock") + + # Initialize as Unix connector with our local socket + super().__init__(path=self._local_socket_path) + + def _load_ssh_config(self) -> Dict[str, Any]: + """Load SSH configuration from ~/.ssh/config like docker-py does.""" + if SSHConfig is None: + log.debug("SSH config parsing not available (paramiko not installed)") + return {} + + config_options = {} + ssh_config_path = Path.home() / ".ssh" / "config" + + if ssh_config_path.exists(): + try: + config = SSHConfig.from_path(ssh_config_path) + host_config = config.lookup(self._ssh_host) + + # Map SSH config options to asyncssh parameters + # Only use config port if not specified in URL + if "port" in host_config and self._ssh_port == DEFAULT_SSH_PORT: + self._ssh_port = int(host_config["port"]) + # Only use config user if not specified in URL + if "user" in host_config and not self._ssh_username: + self._ssh_username = host_config["user"] + # Map file paths directly + if "identityfile" in host_config: + config_options["client_keys"] = host_config["identityfile"] + if "userknownhostsfile" in host_config: + config_options["known_hosts"] = host_config["userknownhostsfile"] + + log.debug("Loaded SSH config for %s", self._ssh_host) + + except Exception: + log.exception("Failed to parse SSH config") + + return config_options + + def _setup_host_key_verification(self) -> None: + """Setup host key verification following docker-py security principles.""" + known_hosts = self._ssh_options.get("known_hosts") + + # If no known_hosts specified in config, use default location + if known_hosts is None: + default_known_hosts = Path.home() / ".ssh" / "known_hosts" + if default_known_hosts.exists(): + self._ssh_options["known_hosts"] = str(default_known_hosts) + known_hosts = str(default_known_hosts) + + if known_hosts is None and self._strict_host_keys: + # Docker-py equivalent: enforce host key checking + raise ValueError( + "Host key verification is required for security. " + "Either add the host to ~/.ssh/known_hosts or set strict_host_keys=False. " + "SECURITY WARNING: Disabling host key verification makes connections " + "vulnerable to man-in-the-middle attacks." + ) + elif known_hosts is None: + # Allow but warn (similar to docker-py's WarningPolicy) + log.warning( + "SECURITY WARNING: Host key verification disabled for %(ssh_host)s. " + "Connection is vulnerable to man-in-the-middle attacks. " + "Add host to ~/.ssh/known_hosts or run: ssh-keyscan -H %(ssh_host)s >> ~/.ssh/known_hosts", + {"ssh_host": self._ssh_host}, + ) + + def _sanitize_error_message(self, error: Exception) -> str: + """Sanitize error messages to prevent credential leakage.""" + message = str(error) + + # Remove password from error messages + if self._ssh_password: + message = message.replace(self._ssh_password, "***REDACTED***") + + # Remove password from SSH URLs in error messages + message = re.sub( + r"ssh://([^:/@]+):([^@]+)@", r"ssh://\1:***REDACTED***@", message + ) + + return message + + def _clean_environment(self) -> Dict[str, str]: + """Clean environment variables for security like docker-py does.""" + env = os.environ.copy() + for var in DANGEROUS_ENV_VARS: + env.pop(var, None) + return env + + async def _ensure_ssh_tunnel(self) -> None: + """Ensure SSH tunnel is established using asyncssh context manager with proper locking.""" + # Use lock to prevent concurrent tunnel creation (docker-py principle) + async with self._tunnel_lock: + # Re-check condition after acquiring lock + if self._ssh_conn is None or self._ssh_conn.is_closed(): + log.debug( + "Establishing SSH connection to %s@%s:%s", + self._ssh_username, + self._ssh_host, + self._ssh_port, + ) + + try: + # Clean environment like docker-py does + clean_env = self._clean_environment() + + # Use asyncssh context manager properly + self._ssh_context = asyncssh.connect( + host=self._ssh_host, + port=self._ssh_port, + username=self._ssh_username, + password=self._ssh_password, + env=clean_env, + **self._ssh_options, + ) + self._ssh_conn = await self._ssh_context.__aenter__() + + # Forward local socket to remote Docker socket + await self._ssh_conn.forward_local_path( + self._local_socket_path, self._socket_path + ) + log.debug( + "SSH tunnel established: local socket -> %s", self._socket_path + ) + + # Clear password from memory after successful connection + if self._ssh_password: + self._ssh_password = None + + except Exception as e: + sanitized_error = self._sanitize_error_message(e) + log.error("Failed to establish SSH connection: %s", sanitized_error) + + # Clean up context if it was created + if self._ssh_context: + try: + await self._ssh_context.__aexit__( + type(e), e, e.__traceback__ + ) + except Exception: + pass + self._ssh_context = None + self._ssh_conn = None + raise + + async def connect( + self, req: aiohttp.ClientRequest, traces: Any, timeout: aiohttp.ClientTimeout + ) -> Connection: + """Connect through SSH tunnel.""" + await self._ensure_ssh_tunnel() + return await super().connect(req, traces, timeout) + + async def close(self) -> None: # type: ignore[override] + """Close SSH connection and clean up resources with proper error handling.""" + await super().close() + + # Close SSH context manager properly + if self._ssh_context: + try: + await self._ssh_context.__aexit__(None, None, None) + except Exception as e: + sanitized_error = self._sanitize_error_message(e) + log.warning("Error closing SSH connection: %s", sanitized_error) + finally: + self._ssh_context = None + self._ssh_conn = None + + # Clean up temporary directory (removes socket file automatically) + try: + self._temp_dir.cleanup() + except Exception as e: + # Don't log full path for security + temp_name = self._temp_dir.name[-8:] if self._temp_dir.name else "unknown" + log.warning( + "Failed to clean up temporary directory : %s", + temp_name, + type(e).__name__, + ) + + # Clear any remaining sensitive data + self._ssh_password = None + + +def parse_ssh_url(url: str) -> Tuple[str, str]: + """Parse SSH URL and extract connection info and socket path. + + Args: + url: SSH URL like ssh://user@host:port///path/to/docker.sock + + Returns: + Tuple of (ssh_connection_url, socket_path) + """ + if not url.startswith("ssh://"): + raise ValueError("SSH URL must start with ssh://") + + # Handle the triple slash for absolute path + if "///" in url: + ssh_part, socket_path = url.split("///", 1) + socket_path = "/" + socket_path + else: + ssh_part = url + socket_path = DEFAULT_DOCKER_SOCKET + + return ssh_part, socket_path diff --git a/docs/index.rst b/docs/index.rst index 442739dd..021fa533 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -128,6 +128,7 @@ It's *Apache 2* licensed and freely available. networks secrets services + ssh swarm system volumes diff --git a/docs/ssh.rst b/docs/ssh.rst new file mode 100644 index 00000000..60515f38 --- /dev/null +++ b/docs/ssh.rst @@ -0,0 +1,294 @@ +================ +SSH Connections +================ + +aiodocker supports connecting to remote Docker hosts over SSH using the ``ssh://`` URL scheme. This feature requires the ``asyncssh`` library and follows the same security principles as docker-py. + +Installation +============ + +Install aiodocker with SSH support: + +.. code-block:: bash + + pip install aiodocker[ssh] + +Basic Usage +=========== + +Connect to a remote Docker host using an SSH URL: + +.. code-block:: python + + import asyncio + import aiodocker + + async def main(): + # Connect to Docker over SSH + async with aiodocker.Docker(url="ssh://user@remote-host:22") as docker: + # Use Docker API normally + version = await docker.version() + print(f"Docker version: {version['Version']}") + + # List containers + containers = await docker.containers.list() + for container in containers: + print(f"Container: {container['Names'][0]}") + + if __name__ == "__main__": + asyncio.run(main()) + +URL Format +========== + +SSH URLs follow this format:: + + ssh://[user[:password]@]host[:port][///socket_path] + +Examples: + +* ``ssh://ubuntu@host:22///var/run/docker.sock`` - Full format with custom socket path +* ``ssh://ubuntu@host:22`` - Uses default socket path (``/var/run/docker.sock``) +* ``ssh://ubuntu@host`` - Uses default port (22) and socket path + +Authentication +============== + +SSH Key Authentication (Recommended) +------------------------------------- + +The preferred method is SSH key authentication: + +.. code-block:: python + + # Automatic key discovery from ~/.ssh/config + async with aiodocker.Docker(url="ssh://ubuntu@host:22") as docker: + containers = await docker.containers.list() + + # Specify custom key file + from aiodocker.ssh import SSHConnector + + connector = SSHConnector( + "ssh://ubuntu@host:22", + client_keys=["~/.ssh/docker_key"] + ) + async with aiodocker.Docker(connector=connector) as docker: + containers = await docker.containers.list() + +Password Authentication (Discouraged) +-------------------------------------- + +Passwords can be included in URLs but this is not recommended for security reasons: + +.. code-block:: python + + # Warning: Password will be stored in memory and may appear in logs + async with aiodocker.Docker(url="ssh://ubuntu:password@host:22") as docker: + containers = await docker.containers.list() + +Host Key Verification +===================== + +By default, aiodocker enforces strict host key verification for security. The remote host must be present in ``~/.ssh/known_hosts``. + +Adding Host Keys +---------------- + +Add the remote host to your known hosts file: + +.. code-block:: bash + + # Method 1: Connect manually to add host key + ssh ubuntu@remote-host + + # Method 2: Add host key directly + ssh-keyscan -H remote-host >> ~/.ssh/known_hosts + + # Method 3: Copy from another trusted machine + scp trusted-machine:~/.ssh/known_hosts ~/.ssh/known_hosts + +Relaxing Host Key Verification +------------------------------- + +For testing environments only, you can disable strict host key checking: + +.. code-block:: python + + from aiodocker.ssh import SSHConnector + + # WARNING: Only for testing - vulnerable to man-in-the-middle attacks + connector = SSHConnector( + "ssh://ubuntu@test-host:22", + strict_host_keys=False + ) + async with aiodocker.Docker(connector=connector) as docker: + containers = await docker.containers.list() + + +SSH Configuration +================= + +aiodocker automatically reads SSH configuration from ``~/.ssh/config`` when the ``paramiko`` library is available. + +Example SSH config: + +.. code-block:: text + + Host docker-prod + HostName production.example.com + User dockeruser + Port 2222 + IdentityFile ~/.ssh/docker_prod_key + UserKnownHostsFile ~/.ssh/known_hosts_prod + + Host docker-staging + HostName staging.example.com + User ubuntu + Port 22 + IdentityFile ~/.ssh/docker_staging_key + +Usage with SSH config: + +.. code-block:: python + + # Automatically uses settings from ~/.ssh/config + async with aiodocker.Docker(url="ssh://docker-prod") as docker: + containers = await docker.containers.list() + +Advanced Configuration +====================== + +Custom SSH Options +------------------- + +Pass additional SSH options directly to the underlying asyncssh connection: + +.. code-block:: python + + from aiodocker.ssh import SSHConnector + + connector = SSHConnector( + "ssh://ubuntu@host:22", + # Custom SSH options + compression=True, + keepalive_interval=30, + known_hosts="/custom/path/known_hosts" + ) + async with aiodocker.Docker(connector=connector) as docker: + containers = await docker.containers.list() + +Connection Reuse +---------------- + +For better performance, reuse the same connector across multiple Docker instances: + +.. code-block:: python + + from aiodocker.ssh import SSHConnector + + # Create connector once + connector = SSHConnector("ssh://ubuntu@host:22") + + # Reuse across multiple Docker instances + async with aiodocker.Docker(connector=connector) as docker1: + containers = await docker1.containers.list() + + async with aiodocker.Docker(connector=connector) as docker2: + images = await docker2.images.list() + + # Clean up when done + await connector.close() + +Security Considerations +======================= + +The SSH implementation follows security best practices: + +Environment Sanitization +------------------------- + +Potentially dangerous environment variables are automatically removed from SSH connections: + +* ``LD_LIBRARY_PATH`` +* ``SSL_CERT_FILE`` +* ``SSL_CERT_DIR`` +* ``PYTHONPATH`` + +Credential Protection +--------------------- + +* Passwords are cleared from memory after successful connection +* Error messages sanitize credential information to prevent leakage +* Log messages are filtered to avoid password exposure + +Secure Temporary Files +---------------------- + +Local socket files are created in secure temporary directories with restricted permissions (mode 0700), ensuring only the current user can access them. + +Troubleshooting +=============== + +Common Issues +------------- + +**"Host key verification failed"** + +Add the host to your known hosts file: + +.. code-block:: bash + + ssh-keyscan -H hostname >> ~/.ssh/known_hosts + +**"Permission denied (publickey)"** + +Ensure your SSH key is properly configured: + +.. code-block:: bash + + ssh-add ~/.ssh/your_key + ssh ubuntu@hostname # Test SSH connection manually + +**"Connection refused"** + +Verify the SSH service is running and accessible: + +.. code-block:: bash + + telnet hostname 22 + +Debugging +--------- + +Enable debug logging to troubleshoot connection issues: + +.. code-block:: python + + import logging + logging.basicConfig(level=logging.DEBUG) + + async with aiodocker.Docker(url="ssh://ubuntu@host:22") as docker: + containers = await docker.containers.list() + +Requirements +============ + +* ``asyncssh >= 2.14.0`` (installed automatically with ``aiodocker[ssh]``) +* SSH access to the remote Docker host +* Docker socket accessible on the remote host (usually ``/var/run/docker.sock``) + +---------- +Reference +---------- + +SSHConnector +============ + +.. autoclass:: aiodocker.ssh.SSHConnector + :members: + :undoc-members: + +Functions +========= + +.. autofunction:: aiodocker.ssh.parse_ssh_url diff --git a/examples/ssh_example.py b/examples/ssh_example.py new file mode 100644 index 00000000..b605d1dd --- /dev/null +++ b/examples/ssh_example.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Example of using aiodocker with SSH connections.""" + +import asyncio +import logging + +import aiodocker + + +async def main(): + """Connect to Docker over SSH and show system info.""" + # Configure logging to see connection details + logging.basicConfig(level=logging.DEBUG) + + # Connect to Docker over SSH + # Format: ssh://user@host:port///path/to/docker.sock + docker_host = "ssh://ubuntu@docker-host:22///var/run/docker.sock" + + # You can also use default socket path + # docker_host = "ssh://ubuntu@docker-host:22" + + try: + async with aiodocker.Docker(url=docker_host) as docker: + # Get Docker version info + version = await docker.version() + print(f"Docker version: {version['Version']}") + + # List containers + containers = await docker.containers.list() + print(f"Found {len(containers)} containers") + + # List images + images = await docker.images.list() + print(f"Found {len(images)} images") + + except ImportError: + print("SSH support requires asyncssh. Install with: pip install aiodocker[ssh]") + except Exception as e: + print(f"Error connecting over SSH: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/ssh_example_advanced.py b/examples/ssh_example_advanced.py new file mode 100644 index 00000000..ed6e6209 --- /dev/null +++ b/examples/ssh_example_advanced.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Advanced SSH Docker example demonstrating comprehensive operations.""" + +import argparse +import asyncio +import logging +import sys +import textwrap +import urllib.parse + +import aiodocker + + +async def demonstrate_ssh_docker(docker_host: str): + """Connect to Docker over SSH and demonstrate various operations.""" + # Configure logging to see detailed operations + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + # SECURITY NOTE: This example assumes the remote host is already in ~/.ssh/known_hosts + # Add your host with: ssh-keyscan -H hostname >> ~/.ssh/known_hosts + + print(f"Connecting to Docker via SSH: {docker_host}") + print("=" * 60) + + try: + async with aiodocker.Docker(url=docker_host) as docker: + print("SSH connection established successfully!") + print() + + # 1. Get Docker info + print("Getting Docker system information...") + try: + info = await docker.system.info() + print(f"Docker Engine Version: {info.get('ServerVersion', 'Unknown')}") + print(f" Architecture: {info.get('Architecture', 'Unknown')}") + print(f" Operating System: {info.get('OperatingSystem', 'Unknown')}") + print(f" Kernel Version: {info.get('KernelVersion', 'Unknown')}") + print(f" Total Memory: {info.get('MemTotal', 0) / (1024**3):.2f} GB") + print(f" CPUs: {info.get('NCPU', 'Unknown')}") + print() + except Exception as e: + print(f"Failed to get Docker info: {e}") + print() + + # 2. List containers + print("Listing containers...") + try: + containers = await docker.containers.list( + all=True + ) # Include stopped containers + print(f"Found {len(containers)} containers:") + + if containers: + for container in containers: + container_info = await container.show() + name = container_info["Name"].lstrip("/") + image = container_info["Config"]["Image"] + state = container_info["State"]["Status"] + print(f" • {name:<20} | {image:<30} | {state}") + else: + print(" No containers found") + print() + except Exception as e: + print(f"Failed to list containers: {e}") + print() + + # 3. List images + print("Listing available images...") + try: + images = await docker.images.list() + print(f"Found {len(images)} images:") + + if images: + for image in images: + # Get image details + image_id = image["Id"][:12] # Short ID + repo_tags = image.get("RepoTags", [":"]) + if repo_tags and repo_tags[0] != ":": + tag = repo_tags[0] + else: + tag = ":" + size = image.get("Size", 0) / (1024**2) # MB + + print(f" • {image_id} | {tag:<40} | {size:>8.1f} MB") + else: + print(" No images found") + print() + except Exception as e: + print(f"Failed to list images: {e}") + print() + + # 4. Pull a new image + print("Pulling a new image...") + try: + print(" Pulling alpine:latest...") + await docker.images.pull("alpine:latest") + print("Successfully pulled alpine:latest") + print() + except Exception as e: + print(f"Failed to pull image: {e}") + print() + + # 5. Run a container + print("Running a new container...") + container = None # type: ignore + try: + container = await docker.containers.run( + config={ + "Image": "alpine:latest", + "Cmd": ["echo", "Hello from SSH Docker!"], + "AttachStdout": True, + "AttachStderr": True, + }, + name="ssh-test-container", + ) + print("Container created and started") + + # Wait for container to complete and get logs + await container.wait() + logs = await container.log(stdout=True, stderr=True) + if logs and len(logs) > 0: + if isinstance(logs[0], bytes): + output = logs[0].decode().strip() + else: + output = str(logs[0]).strip() + print(f" Container output: {output}") + else: + print(" Container completed (no output captured)") + print() + except Exception as e: + print(f"Failed to run container: {e}") + print() + + # 6. List containers again to see our new one + print("Listing containers after running new one...") + try: + containers = await docker.containers.list(all=True) + print(f"Found {len(containers)} containers:") + for container_item in containers: + container_info = await container_item.show() + name = container_info["Name"].lstrip("/") + image = container_info["Config"]["Image"] + state = container_info["State"]["Status"] + print(f" • {name:<25} | {image:<30} | {state}") + print() + except Exception as e: + print(f"Failed to list containers: {e}") + print() + + # 7. Clean up - delete the test container + print("Cleaning up test container...") + try: + if container: + await container.delete() + print("Test container deleted successfully") + else: + # Find and delete by name if container object not available + containers = await docker.containers.list(all=True) + for c in containers: + info = await c.show() + if info["Name"] == "/ssh-test-container": + await c.delete() + print("Test container found and deleted") + break + print() + except Exception as e: + print(f"Failed to delete container: {e}") + print() + + # 8. List images again to see the newly pulled one + print("Final image list...") + try: + images = await docker.images.list() + print(f"Found {len(images)} images:") + for image in images: + image_id = image["Id"][:12] + repo_tags = image.get("RepoTags", [":"]) + if repo_tags and repo_tags[0] != ":": + tag = repo_tags[0] + else: + tag = ":" + size = image.get("Size", 0) / (1024**2) + print(f" • {image_id} | {tag:<40} | {size:>8.1f} MB") + print() + except Exception as e: + print(f"Failed to list images: {e}") + print() + + # 9. Optional: Clean up the pulled image (commented out to avoid affecting registry) + # print("Cleaning up pulled image...") + # try: + # await docker.images.delete("alpine:latest") + # print("Alpine image deleted") + # except Exception as e: + # print(f"Failed to delete image: {e}") + + print("Comprehensive SSH Docker test completed successfully!") + + except ImportError: + print("SSH support requires asyncssh. Install with: pip install aiodocker[ssh]") + sys.exit(1) + except ValueError as e: + error_msg = str(e) + if "Host key verification is required" in error_msg: + # Extract hostname from docker_host for the commands + parsed = urllib.parse.urlparse(docker_host) + hostname = parsed.hostname + port = parsed.port if parsed.port else 22 + + print( + textwrap.dedent(f""" + SSH Host Key Verification Failed + ================================================== + Security Issue: The remote host is not in your known_hosts file. + + To fix this, add the host key using one of these methods: + + Method 1 - Add host key automatically: + ssh-keyscan -H {hostname} >> ~/.ssh/known_hosts + + Method 2 - Connect manually first (will prompt to add): + ssh {parsed.username}@{hostname} -p {port} + + Method 3 - Add with specific port: + ssh-keyscan -H -p {port} {hostname} >> ~/.ssh/known_hosts + + SECURITY WARNING: Never disable host key verification in production! + This protects against man-in-the-middle attacks. + """).strip() + ) + else: + print(f"Configuration Error: {e}") + sys.exit(1) + except ConnectionError as e: + print( + textwrap.dedent(f""" + SSH Connection Failed + ============================== + Network Issue: {e} + + Troubleshooting steps: + 1. Verify the hostname and port are correct + 2. Check if SSH service is running on the remote host + 3. Ensure you have network connectivity + 4. Verify your SSH credentials/keys are set up + """).strip() + ) + sys.exit(1) + except Exception as e: + print( + textwrap.dedent(f""" + Unexpected Error: {e} + + For debugging, try running with verbose mode: + python {sys.argv[0]} -v {docker_host} + """).strip() + ) + sys.exit(1) + + +def main(): + """Main function to parse arguments and run the SSH Docker demonstration.""" + parser = argparse.ArgumentParser( + description="Advanced SSH Docker operations demonstration", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s ssh://ubuntu@docker-host.example.com:22 + %(prog)s ssh://user@192.168.1.100:2222///var/run/docker.sock + %(prog)s ssh://admin@prod-docker:22 + +Security Notes: + - Ensure the remote host is in your ~/.ssh/known_hosts file + - Use SSH key authentication for better security + - Add host keys with: ssh-keyscan -H hostname >> ~/.ssh/known_hosts + """, + ) + parser.add_argument( + "docker_host", help="SSH URL for Docker host (e.g., ssh://user@host:port)" + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose logging" + ) + + args = parser.parse_args() + + # Validate SSH URL format + if not args.docker_host.startswith("ssh://"): + print("Error: Docker host must be an SSH URL (ssh://user@host:port)") + sys.exit(1) + + # Set logging level + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + # Run the demonstration + asyncio.run(demonstrate_ssh_docker(args.docker_host)) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index c2738c1c..1898b69b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,10 @@ dependencies = [ ] [project.optional-dependencies] +ssh = [ + "asyncssh>=2.14.0", + "paramiko>=2.9.0", +] dev = [ "aiohttp==3.13.2", "towncrier==25.8.0", @@ -34,6 +38,7 @@ lint = [ "pre-commit>=3.5.0", "ruff==0.14.3", "mypy==1.18.2", + "types-paramiko", ] test = [ "async-timeout~=5.0.1", @@ -44,6 +49,9 @@ test = [ "pytest-cov==7.0.0", "pytest-sugar==1.1.1", "testcontainers==4.13.2", + # SSH dependencies for testing SSH functionality + "asyncssh>=2.14.0", + "paramiko>=2.9.0", ] doc = [ "alabaster==1.0.0", diff --git a/tests/test_ssh.py b/tests/test_ssh.py new file mode 100644 index 00000000..60d27098 --- /dev/null +++ b/tests/test_ssh.py @@ -0,0 +1,87 @@ +"""Tests for SSH connection support.""" + +import pytest + +from aiodocker.ssh import parse_ssh_url + + +class TestSSHSupport: + """Test SSH URL parsing and connection setup.""" + + def test_parse_ssh_url_with_socket_path(self): + """Test parsing SSH URL with explicit socket path.""" + url = "ssh://ubuntu@host:22///var/run/docker.sock" + ssh_url, socket_path = parse_ssh_url(url) + + assert ssh_url == "ssh://ubuntu@host:22" + assert socket_path == "/var/run/docker.sock" + + def test_parse_ssh_url_with_custom_socket(self): + """Test parsing SSH URL with custom socket path.""" + url = "ssh://user@example.com:2222///foo/bar/docker.sock" + ssh_url, socket_path = parse_ssh_url(url) + + assert ssh_url == "ssh://user@example.com:2222" + assert socket_path == "/foo/bar/docker.sock" + + def test_parse_ssh_url_default_socket(self): + """Test parsing SSH URL without explicit socket path.""" + url = "ssh://ubuntu@host:22" + ssh_url, socket_path = parse_ssh_url(url) + + assert ssh_url == "ssh://ubuntu@host:22" + assert socket_path == "/var/run/docker.sock" + + def test_parse_ssh_url_invalid_scheme(self): + """Test parsing invalid URL scheme.""" + with pytest.raises(ValueError, match="SSH URL must start with ssh://"): + parse_ssh_url("http://example.com") + + def test_ssh_connector_import_error(self): + """Test SSH connector raises ImportError when asyncssh not available.""" + # Mock missing asyncssh + import aiodocker.ssh + + original_asyncssh = aiodocker.ssh.asyncssh + aiodocker.ssh.asyncssh = None + + try: + from aiodocker.ssh import SSHConnector + + with pytest.raises(ImportError, match="asyncssh is required"): + SSHConnector("ssh://user@host") + finally: + aiodocker.ssh.asyncssh = original_asyncssh + + def test_ssh_connector_invalid_url_scheme(self): + """Test SSH connector rejects invalid URL schemes.""" + with pytest.raises(ValueError, match="Invalid SSH URL scheme"): + from aiodocker.ssh import SSHConnector + + SSHConnector("http://user@host") + + def test_ssh_connector_missing_hostname(self): + """Test SSH connector requires hostname.""" + with pytest.raises(ValueError, match="SSH URL must include hostname"): + from aiodocker.ssh import SSHConnector + + SSHConnector("ssh://user@") + + def test_ssh_connector_missing_username(self): + """Test SSH connector requires username.""" + with pytest.raises(ValueError, match="SSH URL must include username"): + from aiodocker.ssh import SSHConnector + + SSHConnector("ssh://host:22") + + def test_ssh_connector_invalid_port(self): + """Test SSH connector validates port range.""" + with pytest.raises(ValueError, match="Port out of range"): + from aiodocker.ssh import SSHConnector + + SSHConnector("ssh://user@host:70000") + + with pytest.raises(ValueError, match="Port could not be cast to integer"): + from aiodocker.ssh import SSHConnector + + SSHConnector("ssh://user@host:-1")