""" SSH client wrapper — connect, exec, sftp, key management via paramiko. """ import os import platform import paramiko from core.logger import log class SSHClientWrapper: def __init__(self, server: dict, key_path: str = ""): self.server = server self.key_path = key_path or os.path.expanduser("~/.ssh/id_ed25519") self._client: paramiko.SSHClient | None = None def connect(self) -> paramiko.SSHClient: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) kwargs = { "hostname": self.server["ip"], "port": self.server.get("port", 22), "username": self.server.get("user", "root"), "timeout": 15, "banner_timeout": 15, } # Try key first if os.path.exists(self.key_path): try: kwargs["key_filename"] = self.key_path client.connect(**kwargs) self._client = client return client except paramiko.AuthenticationException: log.debug(f"Key auth failed for {self.server.get('alias', '?')}, trying password") del kwargs["key_filename"] client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) except Exception as e: log.debug(f"Key connect failed: {e}") del kwargs["key_filename"] client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # Fallback to password password = self.server.get("password", "") if password: kwargs["password"] = password kwargs["look_for_keys"] = False kwargs["allow_agent"] = False client.connect(**kwargs) self._client = client return client raise Exception(f"No auth method for {self.server.get('alias', 'unknown')}") def disconnect(self): if self._client: try: self._client.close() except Exception: pass self._client = None def exec_command(self, command: str, use_sudo: bool = True) -> tuple[str, str, int]: """Execute command. Auto-sudo if user != root and use_sudo=True.""" client = self.connect() stdin = stdout = stderr = None try: user = self.server.get("user", "root") need_sudo = use_sudo and user != "root" if need_sudo: full_cmd = f"sudo -S -p '' bash -c {_shell_quote(command)}" else: full_cmd = command stdin, stdout, stderr = client.exec_command(full_cmd, timeout=120) if need_sudo: password = self.server.get("password", "") stdin.write(password + "\n") stdin.flush() exit_code = stdout.channel.recv_exit_status() out = stdout.read().decode("utf-8", errors="replace") err = stderr.read().decode("utf-8", errors="replace") # Strip sudo noise err_lines = [l for l in err.splitlines() if not l.startswith("[sudo]") and "password for" not in l.lower()] err = "\n".join(err_lines).strip() return out, err, exit_code finally: # Close channels explicitly for ch in (stdin, stdout, stderr): if ch: try: ch.close() except Exception: pass client.close() def upload(self, local_path: str, remote_path: str, progress_cb=None): client = self.connect() try: sftp = client.open_sftp() if progress_cb: sftp.put(local_path, remote_path, callback=progress_cb) else: sftp.put(local_path, remote_path) sftp.chmod(remote_path, 0o664) sftp.close() finally: client.close() def download(self, remote_path: str, local_path: str, progress_cb=None): client = self.connect() try: sftp = client.open_sftp() if progress_cb: sftp.get(remote_path, local_path, callback=progress_cb) else: sftp.get(remote_path, local_path) sftp.close() finally: client.close() def check_connection(self) -> bool: try: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) kwargs = { "hostname": self.server["ip"], "port": self.server.get("port", 22), "username": self.server.get("user", "root"), "timeout": 5, "banner_timeout": 5, } if os.path.exists(self.key_path): try: kwargs["key_filename"] = self.key_path client.connect(**kwargs) client.close() return True except Exception: del kwargs["key_filename"] client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) password = self.server.get("password", "") if password: kwargs["password"] = password kwargs["look_for_keys"] = False kwargs["allow_agent"] = False client.connect(**kwargs) client.close() return True return False except Exception: return False def install_key(self) -> str: pub_key_path = self.key_path + ".pub" if not os.path.exists(pub_key_path): raise FileNotFoundError(f"No public key at {pub_key_path}") with open(pub_key_path, "r") as f: pub_key = f.read().strip() out, _, _ = self.exec_command( f'grep -c "{pub_key}" ~/.ssh/authorized_keys 2>/dev/null || echo 0', use_sudo=False ) if out.strip() != "0": return "Key already installed" command = ( f'mkdir -p ~/.ssh && chmod 700 ~/.ssh && ' f'echo "{pub_key}" >> ~/.ssh/authorized_keys && ' f'chmod 600 ~/.ssh/authorized_keys && ' f'echo "KEY_OK"' ) out, err, code = self.exec_command(command, use_sudo=False) if "KEY_OK" in out: return "Key installed successfully" raise Exception(f"Key install failed: {err or out}") def generate_key(self) -> str: if os.path.exists(self.key_path): return f"Key already exists: {self.key_path}" os.makedirs(os.path.dirname(self.key_path), exist_ok=True) key = paramiko.Ed25519Key.generate() key.write_private_key_file(self.key_path) # Set restrictive permissions on private key (Unix) if platform.system() != "Windows": os.chmod(self.key_path, 0o600) pub_key = f"ssh-ed25519 {key.get_base64()} server-manager" with open(self.key_path + ".pub", "w") as f: f.write(pub_key + "\n") log.info(f"SSH key generated: {self.key_path}") return f"Key generated: {self.key_path}" def _shell_quote(s: str) -> str: return "'" + s.replace("'", "'\\''") + "'"