From b64724888dea806e5cc2726cca4b7cd38db1c77b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 12:12:39 -0500 Subject: [PATCH 1/2] Add SSH tunnel support for MySQL connections (#1) * Initial plan * Add SSH tunnel support for MySQL connections Co-authored-by: tkuhlengel <897141+tkuhlengel@users.noreply.github.com> * Add SSH tunnel documentation and tests Co-authored-by: tkuhlengel <897141+tkuhlengel@users.noreply.github.com> * Address code review feedback - improve error handling and validation Co-authored-by: tkuhlengel <897141+tkuhlengel@users.noreply.github.com> * Improve exception handling and documentation Co-authored-by: tkuhlengel <897141+tkuhlengel@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tkuhlengel <897141+tkuhlengel@users.noreply.github.com> --- README.md | 78 +++++++++++++ guacalib.ini.example | 15 +++ guacalib/db.py | 255 +++++++++++++++++++++++++++++++++++++++++-- requirements.txt | 1 + 4 files changed, 341 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index acedafc..15ce08e 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,84 @@ with GuacamoleDB() as db: All four variables must be set for environment-based configuration to be used. +### Option 3: SSH Tunnel for Remote MySQL Access + +For secure access to remote MySQL databases, guacalib supports SSH tunneling. This allows you to connect to a MySQL database through an SSH server, encrypting all database traffic. + +**Configuration File Method:** + +Add SSH tunnel settings to your `.guacaman.ini` file: +```ini +[mysql] +host = remote-mysql-server.example.com +user = guacamole_user +password = your_password +database = guacamole_db + +# SSH tunnel configuration +ssh_tunnel_enabled = true +ssh_tunnel_host = ssh-gateway.example.com +ssh_tunnel_port = 22 +ssh_tunnel_user = ssh_username + +# Option 1: Password authentication +ssh_tunnel_password = ssh_password + +# Option 2: SSH key authentication (recommended) +# ssh_tunnel_private_key = /home/user/.ssh/id_rsa +# ssh_tunnel_private_key_passphrase = key_passphrase +``` + +**Environment Variables Method:** + +```bash +# MySQL configuration +export GUACALIB_HOST=remote-mysql-server.example.com +export GUACALIB_USER=guacamole_user +export GUACALIB_PASSWORD=your_password +export GUACALIB_DATABASE=guacamole_db + +# SSH tunnel configuration +export GUACALIB_SSH_TUNNEL_ENABLED=true +export GUACALIB_SSH_TUNNEL_HOST=ssh-gateway.example.com +export GUACALIB_SSH_TUNNEL_PORT=22 +export GUACALIB_SSH_TUNNEL_USER=ssh_username + +# Option 1: Password authentication +export GUACALIB_SSH_TUNNEL_PASSWORD=ssh_password + +# Option 2: SSH key authentication (recommended) +# export GUACALIB_SSH_TUNNEL_PRIVATE_KEY=/home/user/.ssh/id_rsa +# export GUACALIB_SSH_TUNNEL_PRIVATE_KEY_PASSPHRASE=key_passphrase +``` + +**SSH Tunnel Environment Variables:** +- `GUACALIB_SSH_TUNNEL_ENABLED`: Enable SSH tunnel (true/false) +- `GUACALIB_SSH_TUNNEL_HOST`: SSH server hostname +- `GUACALIB_SSH_TUNNEL_PORT`: SSH server port (default: 22) +- `GUACALIB_SSH_TUNNEL_USER`: SSH username +- `GUACALIB_SSH_TUNNEL_PASSWORD`: SSH password (optional) +- `GUACALIB_SSH_TUNNEL_PRIVATE_KEY`: Path to SSH private key file (optional) +- `GUACALIB_SSH_TUNNEL_PRIVATE_KEY_PASSPHRASE`: Private key passphrase (optional) + +**Usage Example:** +```python +from guacalib import GuacamoleDB + +# SSH tunnel will be automatically established if configured +with GuacamoleDB('~/.guacaman.ini') as db: + users = db.list_users() + # SSH tunnel is active during this block +# SSH tunnel is automatically closed when exiting the context +``` + +**Notes:** +- SSH tunnel is established automatically when entering the context manager +- The tunnel is closed automatically when exiting the context manager +- You must provide either `ssh_tunnel_password` or `ssh_tunnel_private_key` (not both) +- SSH key authentication is recommended for better security +- The tunnel forwards the MySQL connection securely through the SSH server + ## Library Documentation The `guacalib` library includes comprehensive API documentation with Google-style docstrings throughout the codebase. You can: diff --git a/guacalib.ini.example b/guacalib.ini.example index 72d0cf3..edb3e08 100644 --- a/guacalib.ini.example +++ b/guacalib.ini.example @@ -4,3 +4,18 @@ host = localhost user = guacamole_user password = your_password database = guacamole_db + +# Optional: SSH tunnel configuration for secure remote MySQL access +# Uncomment and configure these settings to tunnel MySQL connection over SSH +# ssh_tunnel_enabled = true +# ssh_tunnel_host = ssh.example.com +# ssh_tunnel_port = 22 +# ssh_tunnel_user = ssh_username + +# SSH authentication - use either password OR private key (not both) +# Option 1: Password authentication +# ssh_tunnel_password = ssh_password + +# Option 2: SSH key authentication (recommended) +# ssh_tunnel_private_key = /home/user/.ssh/id_rsa +# ssh_tunnel_private_key_passphrase = key_passphrase diff --git a/guacalib/db.py b/guacalib/db.py index 6147746..03e3689 100644 --- a/guacalib/db.py +++ b/guacalib/db.py @@ -9,12 +9,14 @@ from typing import Optional, Union, Dict, List, Tuple, Any, Type, Callable from mysql.connector.connection import MySQLConnection +from sshtunnel import SSHTunnelForwarder from .db_connection_parameters import CONNECTION_PARAMETERS from .db_user_parameters import USER_PARAMETERS # Custom type definitions ConnectionConfig = Dict[str, str] +SSHTunnelConfig = Dict[str, Union[str, int, bool]] ConnectionParameters = Dict[str, Union[str, int, bool]] UserParameters = Dict[str, Dict[str, Union[str, str, str]]] UserInfo = Tuple[str, List[str]] @@ -85,9 +87,15 @@ def __init__(self, config_file: str = "db_config.ini", debug: bool = False) -> N password, and database keys. Environment variables take precedence over configuration file settings. + + SSH tunnel configuration is optional and can be specified in the config file + or via environment variables. When enabled, the MySQL connection will be + tunneled through SSH for secure remote access. """ self.debug = debug + self.ssh_tunnel = None self.db_config = self.read_config(config_file) + self.ssh_tunnel_config = self.read_ssh_tunnel_config(config_file) self.conn = self.connect_db() self.cursor = self.conn.cursor() @@ -118,7 +126,7 @@ def __exit__( """Exit the runtime context and handle database connection cleanup. Commits transactions if no exception occurred, rolls back if there was - an exception, and closes database connections. + an exception, and closes database connections and SSH tunnel. Args: exc_type: Exception type if an exception occurred, None otherwise. @@ -136,6 +144,12 @@ def __exit__( self.conn.rollback() finally: self.conn.close() + if self.ssh_tunnel: + try: + self.ssh_tunnel.stop() + self.debug_print("SSH tunnel closed") + except Exception as e: + self.debug_print(f"Error closing SSH tunnel: {e}") @staticmethod def read_config(config_file: str) -> ConnectionConfig: @@ -205,7 +219,9 @@ def read_config(config_file: str) -> ConnectionConfig: config = configparser.ConfigParser() if not os.path.exists(config_file): print(f"Error: Config file not found: {config_file}") - print("Please set environment variables or create a config file at ~/.guacaman.ini") + print( + "Please set environment variables or create a config file at ~/.guacaman.ini" + ) print("\nOption 1: Set environment variables:") print("export GUACALIB_HOST=your_mysql_host") print("export GUACALIB_USER=your_mysql_user") @@ -244,30 +260,253 @@ def read_config(config_file: str) -> ConnectionConfig: print(f"Error reading config file {config_file}: {str(e)}") sys.exit(1) + @staticmethod + def read_ssh_tunnel_config(config_file: str) -> Optional[SSHTunnelConfig]: + """Read SSH tunnel configuration from environment variables or file. + + First checks for SSH tunnel environment variables. If all required environment + variables are present, they are used. Otherwise, falls back to reading from + configuration file if SSH tunnel is enabled there. + + Args: + config_file: Path to the configuration file. + + Returns: + Dictionary containing SSH tunnel parameters if SSH tunnel is enabled, + None otherwise. The dictionary may include keys: 'enabled', 'host', 'port', + 'user', 'password', 'private_key', 'private_key_passphrase'. + + Note: + Environment variables take precedence over configuration file settings. + + Environment variables: + - GUACALIB_SSH_TUNNEL_ENABLED: Enable SSH tunnel (true/false) + - GUACALIB_SSH_TUNNEL_HOST: SSH server hostname + - GUACALIB_SSH_TUNNEL_PORT: SSH server port (default: 22) + - GUACALIB_SSH_TUNNEL_USER: SSH username + - GUACALIB_SSH_TUNNEL_PASSWORD: SSH password (optional) + - GUACALIB_SSH_TUNNEL_PRIVATE_KEY: Path to SSH private key (optional) + - GUACALIB_SSH_TUNNEL_PRIVATE_KEY_PASSPHRASE: Private key passphrase (optional) + + Configuration file format: + [mysql] + ... + ssh_tunnel_enabled = true + ssh_tunnel_host = ssh.example.com + ssh_tunnel_port = 22 + ssh_tunnel_user = ssh_username + ssh_tunnel_password = ssh_password + ssh_tunnel_private_key = /path/to/key + ssh_tunnel_private_key_passphrase = passphrase + """ + # Try environment variables first + env_enabled = os.environ.get("GUACALIB_SSH_TUNNEL_ENABLED", "").lower() + if env_enabled in ("true", "1", "yes"): + # Validate and parse port + port_str = os.environ.get("GUACALIB_SSH_TUNNEL_PORT", "22") + try: + port = int(port_str) + if port < 1 or port > 65535: + print( + f"Error: Invalid SSH tunnel port: {port}. Must be between 1 and 65535." + ) + sys.exit(1) + except ValueError: + print(f"Error: Invalid SSH tunnel port: {port_str}. Must be a number.") + sys.exit(1) + + ssh_config = { + "enabled": True, + "host": os.environ.get("GUACALIB_SSH_TUNNEL_HOST"), + "port": port, + "user": os.environ.get("GUACALIB_SSH_TUNNEL_USER"), + "password": os.environ.get("GUACALIB_SSH_TUNNEL_PASSWORD"), + "private_key": os.environ.get("GUACALIB_SSH_TUNNEL_PRIVATE_KEY"), + "private_key_passphrase": os.environ.get( + "GUACALIB_SSH_TUNNEL_PRIVATE_KEY_PASSPHRASE" + ), + } + + # Validate required fields + if not ssh_config["host"] or not ssh_config["user"]: + print("Error: SSH tunnel enabled but missing required configuration") + print("Required: GUACALIB_SSH_TUNNEL_HOST, GUACALIB_SSH_TUNNEL_USER") + sys.exit(1) + + # Must have either password or private key + if not ssh_config["password"] and not ssh_config["private_key"]: + print("Error: SSH tunnel requires either password or private key") + sys.exit(1) + + return ssh_config + + # Try config file + if not os.path.exists(config_file): + return None + + config = configparser.ConfigParser() + try: + config.read(config_file) + if "mysql" not in config: + return None + + # Check if SSH tunnel is enabled in config + enabled = config["mysql"].get("ssh_tunnel_enabled", "false").lower() + if enabled not in ("true", "1", "yes"): + return None + + # Validate and parse port + port_str = config["mysql"].get("ssh_tunnel_port", "22") + try: + port = int(port_str) + if port < 1 or port > 65535: + print( + f"Error: Invalid SSH tunnel port in config: {port}. Must be between 1 and 65535." + ) + sys.exit(1) + except ValueError: + print( + f"Error: Invalid SSH tunnel port in config: {port_str}. Must be a number." + ) + sys.exit(1) + + ssh_config = { + "enabled": True, + "host": config["mysql"].get("ssh_tunnel_host"), + "port": port, + "user": config["mysql"].get("ssh_tunnel_user"), + "password": config["mysql"].get("ssh_tunnel_password"), + "private_key": config["mysql"].get("ssh_tunnel_private_key"), + "private_key_passphrase": config["mysql"].get( + "ssh_tunnel_private_key_passphrase" + ), + } + + # Validate required fields + if not ssh_config["host"] or not ssh_config["user"]: + print("Error: SSH tunnel enabled but missing required configuration") + print("Required: ssh_tunnel_host, ssh_tunnel_user") + sys.exit(1) + + # Must have either password or private key + if not ssh_config["password"] and not ssh_config["private_key"]: + print("Error: SSH tunnel requires either password or private key") + sys.exit(1) + + return ssh_config + + except Exception as e: + print(f"Error reading SSH tunnel config from {config_file}: {str(e)}") + return None + def connect_db(self) -> MySQLConnection: """Establish MySQL database connection using loaded configuration. Creates a MySQL connection using the configuration loaded by read_config(). - Sets UTF8MB4 charset and collation for proper Unicode support. + Sets UTF8MB4 charset and collation for proper Unicode support. If SSH tunnel + is configured, establishes the tunnel first and connects through it. Returns: MySQLConnection object for database operations. Raises: - SystemExit: If database connection fails. + SystemExit: If database connection or SSH tunnel setup fails. mysql.connector.Error: For various MySQL connection errors. + paramiko.SSHException: For SSH tunnel authentication or connection errors. + Exception: For other SSH tunnel or configuration errors. Note: Uses UTF8MB4 charset to support full Unicode including emoji and special characters. Connection parameters are loaded from the configuration file during initialization. + + If SSH tunnel is enabled, the connection will be made through the tunnel + using a local port forwarding. The tunnel is automatically started and + the database host/port are adjusted accordingly. Any errors during SSH + tunnel setup or MySQL connection will result in proper cleanup and + a SystemExit with an error message. """ try: - return mysql.connector.connect( - **self.db_config, charset="utf8mb4", collation="utf8mb4_general_ci" - ) + # Set up SSH tunnel if configured + if self.ssh_tunnel_config: + self.debug_print("Setting up SSH tunnel...") + + # Prepare SSH tunnel parameters + # Validate and parse MySQL port + mysql_port_str = self.db_config.get("port", "3306") + try: + mysql_port = int(mysql_port_str) + if mysql_port < 1 or mysql_port > 65535: + raise ValueError( + f"MySQL port {mysql_port} out of valid range (1-65535)" + ) + except (ValueError, TypeError) as e: + print(f"Error: Invalid MySQL port: {mysql_port_str}. {e}") + sys.exit(1) + + ssh_kwargs = { + "ssh_address_or_host": ( + self.ssh_tunnel_config["host"], + self.ssh_tunnel_config["port"], + ), + "ssh_username": self.ssh_tunnel_config["user"], + "remote_bind_address": (self.db_config["host"], mysql_port), + } + + # Add authentication method + if self.ssh_tunnel_config.get("private_key"): + ssh_kwargs["ssh_pkey"] = self.ssh_tunnel_config["private_key"] + if self.ssh_tunnel_config.get("private_key_passphrase"): + ssh_kwargs["ssh_private_key_password"] = self.ssh_tunnel_config[ + "private_key_passphrase" + ] + elif self.ssh_tunnel_config.get("password"): + ssh_kwargs["ssh_password"] = self.ssh_tunnel_config["password"] + + # Create and start the SSH tunnel + self.ssh_tunnel = SSHTunnelForwarder(**ssh_kwargs) + self.ssh_tunnel.start() + + self.debug_print( + f"SSH tunnel established: localhost:{self.ssh_tunnel.local_bind_port} -> " + f"{self.ssh_tunnel_config['host']}:{self.ssh_tunnel_config['port']} -> " + f"{self.db_config['host']}:{self.db_config.get('port', 3306)}" + ) + + # Connect to MySQL through the tunnel + db_config = self.db_config.copy() + db_config["host"] = "127.0.0.1" + db_config["port"] = self.ssh_tunnel.local_bind_port + + return mysql.connector.connect( + **db_config, charset="utf8mb4", collation="utf8mb4_general_ci" + ) + else: + # Direct connection without tunnel + return mysql.connector.connect( + **self.db_config, charset="utf8mb4", collation="utf8mb4_general_ci" + ) except mysql.connector.Error as e: - print(f"Error connecting to database: {e}") + # MySQL connection error + if self.ssh_tunnel: + try: + self.ssh_tunnel.stop() + except Exception as tunnel_error: + self.debug_print( + f"Error stopping SSH tunnel during cleanup: {tunnel_error}" + ) + print(f"Error connecting to MySQL database: {e}") + sys.exit(1) + except Exception as e: + # SSH tunnel or other configuration error + if self.ssh_tunnel: + try: + self.ssh_tunnel.stop() + except Exception as tunnel_error: + self.debug_print( + f"Error stopping SSH tunnel during cleanup: {tunnel_error}" + ) + print(f"Error establishing connection (SSH tunnel or configuration): {e}") sys.exit(1) def list_users(self) -> List[str]: diff --git a/requirements.txt b/requirements.txt index dfe5aef..66bbf25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ mysql-connector-python>=8.0.0 configparser>=5.0.0 +sshtunnel>=0.4.0 black pdoc From 4fae6fde5e3fdf8fa8f2f5d1baf0b9ebc8ff9029 Mon Sep 17 00:00:00 2001 From: Trevor Kuhlengel Date: Tue, 4 Nov 2025 13:50:35 -0500 Subject: [PATCH 2/2] Adding sshtunnel dependency to pyproject.toml --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2830c26..93139e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,8 @@ build-backend = "setuptools.build_meta" dynamic = ["version"] dependencies = [ "mysql-connector-python>=8.0.0", - "configparser>=5.0.0" + "configparser>=5.0.0", + "sshtunnel>=0.4.0", ] name = "guacalib" description = "Guacamole management library and CLI"