From 53ded968ef8c7f7d05ea14801dc800d44042b9b1 Mon Sep 17 00:00:00 2001 From: chiconws Date: Sat, 20 Sep 2025 18:45:45 -0300 Subject: [PATCH 1/7] feat: add SSH terminal access support - Add NanoKVMSSH class and paramiko dependency - Add SSH usage examples to README.md --- README.md | 18 ++++++++- nanokvm/ssh_client.py | 91 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 nanokvm/ssh_client.py diff --git a/README.md b/README.md index 395e64c..95a0970 100644 --- a/README.md +++ b/README.md @@ -25,4 +25,20 @@ async with ClientSession() as session: print(frame) await client.push_button(ButtonType.POWER, duration_ms=1000) -``` \ No newline at end of file +``` + +## SSH Usage + +```python +from nanokvm.ssh_client import NanoKVMSSH + +# Create SSH client +ssh = NanoKVMSSH("kvm-8b76.local") +await ssh.authenticate("password") + +# Run commands +uptime = await ssh.run_command("cat /proc/uptime") +disk = await ssh.run_command("df -h /") + +await ssh.disconnect() +``` diff --git a/nanokvm/ssh_client.py b/nanokvm/ssh_client.py new file mode 100644 index 0000000..2feb7cc --- /dev/null +++ b/nanokvm/ssh_client.py @@ -0,0 +1,91 @@ +"""SSH client for NanoKVM terminal access.""" + +from __future__ import annotations + +import asyncio + +import paramiko + +DEFAULT_SSH_USERNAME = "root" + + +class NanoKVMSSHError(Exception): + """Base exception for SSH client errors.""" + + +class NanoKVMSSHNotConnectedError(NanoKVMSSHError): + """Exception for when SSH client is not connected.""" + + +class NanoKVMSSHAuthenticationError(NanoKVMSSHError): + """Exception for SSH authentication failures.""" + + +class NanoKVMSSHCommandError(NanoKVMSSHError): + """Exception for SSH command execution errors.""" + + +class NanoKVMSSH: + """SSH client for NanoKVM terminal access.""" + + def __init__(self, host: str, username: str = DEFAULT_SSH_USERNAME, port: int = 22) -> None: + """Initialize the SSH client.""" + self.host = host + self.port = port + self.username = username + self.ssh_client: paramiko.SSHClient | None = None + + async def authenticate(self, password: str) -> None: + """Authenticate with SSH using password.""" + loop = asyncio.get_running_loop() + self.ssh_client = paramiko.SSHClient() + self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + await loop.run_in_executor( + None, + lambda: self.ssh_client.connect( + self.host, + port=self.port, + username=self.username, + password=password, + timeout=10 + ) + ) + except paramiko.AuthenticationException as e: + raise NanoKVMSSHAuthenticationError(f"SSH authentication failed: {e}") + except (paramiko.SSHException, paramiko.BadHostKeyException, OSError) as e: + raise NanoKVMSSHAuthenticationError(f"SSH connection failed: {e}") + + async def disconnect(self) -> None: + """Close SSH connection.""" + if self.ssh_client: + self.ssh_client.close() + self.ssh_client = None + + async def run_command(self, command: str, timeout: int = 30) -> str: + """Run a command via SSH and return output.""" + if not self.ssh_client: + raise NanoKVMSSHNotConnectedError("SSH not connected, call authenticate first") + loop = asyncio.get_running_loop() + try: + output, error = await asyncio.wait_for( + loop.run_in_executor( + None, self._exec_command_sync, command + ), + timeout=timeout + ) + if error: + raise NanoKVMSSHCommandError(f"SSH command error: {error}") + return output.strip() + except asyncio.TimeoutError: + raise NanoKVMSSHCommandError( + f"SSH command timed out after {timeout} seconds" + ) + + def _exec_command_sync(self, command: str) -> tuple[str, str]: + """Synchronous SSH command execution.""" + stdin, stdout, stderr = self.ssh_client.exec_command(command) + output = stdout.read().decode('utf-8') + error = stderr.read().decode('utf-8') + return output, error diff --git a/pyproject.toml b/pyproject.toml index 19cdabe..d818ad7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "yarl", "pillow", "pydantic", + "paramiko", ] [tool.setuptools.packages.find] From eda0ee1aff623c6f58e854c64e06e8ad82ea97e5 Mon Sep 17 00:00:00 2001 From: chiconws Date: Sat, 20 Sep 2025 20:55:13 -0300 Subject: [PATCH 2/7] fix: resolve CI code quality issues - Fix line length violations (Ruff E501) - Add proper exception chaining (Ruff B904) - Fix MyPy union attribute errors with type assertions - Improve code formatting and readability --- nanokvm/ssh_client.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/nanokvm/ssh_client.py b/nanokvm/ssh_client.py index 2feb7cc..d18eca9 100644 --- a/nanokvm/ssh_client.py +++ b/nanokvm/ssh_client.py @@ -28,7 +28,9 @@ class NanoKVMSSHCommandError(NanoKVMSSHError): class NanoKVMSSH: """SSH client for NanoKVM terminal access.""" - def __init__(self, host: str, username: str = DEFAULT_SSH_USERNAME, port: int = 22) -> None: + def __init__( + self, host: str, username: str = DEFAULT_SSH_USERNAME, port: int = 22 + ) -> None: """Initialize the SSH client.""" self.host = host self.port = port @@ -42,9 +44,10 @@ async def authenticate(self, password: str) -> None: self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: + client = self.ssh_client # Capture for lambda await loop.run_in_executor( None, - lambda: self.ssh_client.connect( + lambda: client.connect( self.host, port=self.port, username=self.username, @@ -53,9 +56,9 @@ async def authenticate(self, password: str) -> None: ) ) except paramiko.AuthenticationException as e: - raise NanoKVMSSHAuthenticationError(f"SSH authentication failed: {e}") + raise NanoKVMSSHAuthenticationError(f"SSH authentication failed: {e}") from e except (paramiko.SSHException, paramiko.BadHostKeyException, OSError) as e: - raise NanoKVMSSHAuthenticationError(f"SSH connection failed: {e}") + raise NanoKVMSSHAuthenticationError(f"SSH connection failed: {e}") from e async def disconnect(self) -> None: """Close SSH connection.""" @@ -66,7 +69,9 @@ async def disconnect(self) -> None: async def run_command(self, command: str, timeout: int = 30) -> str: """Run a command via SSH and return output.""" if not self.ssh_client: - raise NanoKVMSSHNotConnectedError("SSH not connected, call authenticate first") + raise NanoKVMSSHNotConnectedError( + "SSH not connected, call authenticate first" + ) loop = asyncio.get_running_loop() try: output, error = await asyncio.wait_for( @@ -81,10 +86,11 @@ async def run_command(self, command: str, timeout: int = 30) -> str: except asyncio.TimeoutError: raise NanoKVMSSHCommandError( f"SSH command timed out after {timeout} seconds" - ) + ) from None def _exec_command_sync(self, command: str) -> tuple[str, str]: """Synchronous SSH command execution.""" + assert self.ssh_client is not None # Should be set after authenticate() stdin, stdout, stderr = self.ssh_client.exec_command(command) output = stdout.read().decode('utf-8') error = stderr.read().decode('utf-8') From 8a78be00fb00e77e57af1b6a0e77d080c3b21077 Mon Sep 17 00:00:00 2001 From: chiconws Date: Sat, 20 Sep 2025 20:58:16 -0300 Subject: [PATCH 3/7] fix: resolve remaining line length violation - Break long exception message line to comply with Ruff E501 --- nanokvm/ssh_client.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/nanokvm/ssh_client.py b/nanokvm/ssh_client.py index d18eca9..2cb2129 100644 --- a/nanokvm/ssh_client.py +++ b/nanokvm/ssh_client.py @@ -56,9 +56,13 @@ async def authenticate(self, password: str) -> None: ) ) except paramiko.AuthenticationException as e: - raise NanoKVMSSHAuthenticationError(f"SSH authentication failed: {e}") from e + raise NanoKVMSSHAuthenticationError( + f"SSH authentication failed: {e}" + ) from e except (paramiko.SSHException, paramiko.BadHostKeyException, OSError) as e: - raise NanoKVMSSHAuthenticationError(f"SSH connection failed: {e}") from e + raise NanoKVMSSHAuthenticationError( + f"SSH connection failed: {e}" + ) from e async def disconnect(self) -> None: """Close SSH connection.""" From 1dca73a33a8e3a75bba667af6dccfe0bad0cb1fa Mon Sep 17 00:00:00 2001 From: chiconws Date: Sat, 20 Sep 2025 21:05:44 -0300 Subject: [PATCH 4/7] style: apply ruff formatting - Add trailing comma for consistency --- nanokvm/ssh_client.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/nanokvm/ssh_client.py b/nanokvm/ssh_client.py index 2cb2129..7c99e00 100644 --- a/nanokvm/ssh_client.py +++ b/nanokvm/ssh_client.py @@ -52,7 +52,7 @@ async def authenticate(self, password: str) -> None: port=self.port, username=self.username, password=password, - timeout=10 + timeout=10, ) ) except paramiko.AuthenticationException as e: @@ -60,9 +60,7 @@ async def authenticate(self, password: str) -> None: f"SSH authentication failed: {e}" ) from e except (paramiko.SSHException, paramiko.BadHostKeyException, OSError) as e: - raise NanoKVMSSHAuthenticationError( - f"SSH connection failed: {e}" - ) from e + raise NanoKVMSSHAuthenticationError(f"SSH connection failed: {e}") from e async def disconnect(self) -> None: """Close SSH connection.""" @@ -82,7 +80,7 @@ async def run_command(self, command: str, timeout: int = 30) -> str: loop.run_in_executor( None, self._exec_command_sync, command ), - timeout=timeout + timeout=timeout, ) if error: raise NanoKVMSSHCommandError(f"SSH command error: {error}") @@ -96,6 +94,6 @@ def _exec_command_sync(self, command: str) -> tuple[str, str]: """Synchronous SSH command execution.""" assert self.ssh_client is not None # Should be set after authenticate() stdin, stdout, stderr = self.ssh_client.exec_command(command) - output = stdout.read().decode('utf-8') - error = stderr.read().decode('utf-8') + output = stdout.read().decode("utf-8") + error = stderr.read().decode("utf-8") return output, error From 038dd29772ea937ffbd1f516c14e6984a0ae325c Mon Sep 17 00:00:00 2001 From: chiconws Date: Sat, 20 Sep 2025 21:23:53 -0300 Subject: [PATCH 5/7] style: apply final ruff formatting - Reformat run_in_executor call to single line --- nanokvm/ssh_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nanokvm/ssh_client.py b/nanokvm/ssh_client.py index 7c99e00..f9adf8c 100644 --- a/nanokvm/ssh_client.py +++ b/nanokvm/ssh_client.py @@ -77,9 +77,7 @@ async def run_command(self, command: str, timeout: int = 30) -> str: loop = asyncio.get_running_loop() try: output, error = await asyncio.wait_for( - loop.run_in_executor( - None, self._exec_command_sync, command - ), + loop.run_in_executor(None, self._exec_command_sync, command), timeout=timeout, ) if error: From ff10810f868d20dd2968b8ec8ee518be77446561 Mon Sep 17 00:00:00 2001 From: chiconws Date: Sat, 20 Sep 2025 21:33:35 -0300 Subject: [PATCH 6/7] style: add trailing comma for consistency --- nanokvm/ssh_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanokvm/ssh_client.py b/nanokvm/ssh_client.py index f9adf8c..7c295a5 100644 --- a/nanokvm/ssh_client.py +++ b/nanokvm/ssh_client.py @@ -53,7 +53,7 @@ async def authenticate(self, password: str) -> None: username=self.username, password=password, timeout=10, - ) + ), ) except paramiko.AuthenticationException as e: raise NanoKVMSSHAuthenticationError( From 8a19218cbef16befcdc69427de89b889743be7d3 Mon Sep 17 00:00:00 2001 From: chiconws Date: Fri, 26 Sep 2025 09:23:45 -0300 Subject: [PATCH 7/7] fix: subclass SSH exceptions from NanoKVMError base class - SSH exceptions now inherit from NanoKVMError for unified error handling - Users can catch all NanoKVM errors (API + SSH) with single base class --- nanokvm/ssh_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nanokvm/ssh_client.py b/nanokvm/ssh_client.py index 7c295a5..3210a55 100644 --- a/nanokvm/ssh_client.py +++ b/nanokvm/ssh_client.py @@ -6,10 +6,12 @@ import paramiko +from .client import NanoKVMError + DEFAULT_SSH_USERNAME = "root" -class NanoKVMSSHError(Exception): +class NanoKVMSSHError(NanoKVMError): """Base exception for SSH client errors."""