Skip to content

Commit 6bebcf4

Browse files
authored
feat: add SSH terminal access support (puddly#5)
* feat: add SSH terminal access support - Add NanoKVMSSH class and paramiko dependency - Add SSH usage examples to README.md * 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 * fix: resolve remaining line length violation - Break long exception message line to comply with Ruff E501 * style: apply ruff formatting - Add trailing comma for consistency * style: apply final ruff formatting - Reformat run_in_executor call to single line * style: add trailing comma for consistency * 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
1 parent 9a83281 commit 6bebcf4

3 files changed

Lines changed: 116 additions & 0 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,19 @@ async with ClientSession() as session:
2626

2727
await client.push_button(GpioType.POWER, duration_ms=1000)
2828
```
29+
30+
## SSH Usage
31+
32+
```python
33+
from nanokvm.ssh_client import NanoKVMSSH
34+
35+
# Create SSH client
36+
ssh = NanoKVMSSH("kvm-8b76.local")
37+
await ssh.authenticate("password")
38+
39+
# Run commands
40+
uptime = await ssh.run_command("cat /proc/uptime")
41+
disk = await ssh.run_command("df -h /")
42+
43+
await ssh.disconnect()
44+
```

nanokvm/ssh_client.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""SSH client for NanoKVM terminal access."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
7+
import paramiko
8+
9+
from .client import NanoKVMError
10+
11+
DEFAULT_SSH_USERNAME = "root"
12+
13+
14+
class NanoKVMSSHError(NanoKVMError):
15+
"""Base exception for SSH client errors."""
16+
17+
18+
class NanoKVMSSHNotConnectedError(NanoKVMSSHError):
19+
"""Exception for when SSH client is not connected."""
20+
21+
22+
class NanoKVMSSHAuthenticationError(NanoKVMSSHError):
23+
"""Exception for SSH authentication failures."""
24+
25+
26+
class NanoKVMSSHCommandError(NanoKVMSSHError):
27+
"""Exception for SSH command execution errors."""
28+
29+
30+
class NanoKVMSSH:
31+
"""SSH client for NanoKVM terminal access."""
32+
33+
def __init__(
34+
self, host: str, username: str = DEFAULT_SSH_USERNAME, port: int = 22
35+
) -> None:
36+
"""Initialize the SSH client."""
37+
self.host = host
38+
self.port = port
39+
self.username = username
40+
self.ssh_client: paramiko.SSHClient | None = None
41+
42+
async def authenticate(self, password: str) -> None:
43+
"""Authenticate with SSH using password."""
44+
loop = asyncio.get_running_loop()
45+
self.ssh_client = paramiko.SSHClient()
46+
self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
47+
48+
try:
49+
client = self.ssh_client # Capture for lambda
50+
await loop.run_in_executor(
51+
None,
52+
lambda: client.connect(
53+
self.host,
54+
port=self.port,
55+
username=self.username,
56+
password=password,
57+
timeout=10,
58+
),
59+
)
60+
except paramiko.AuthenticationException as e:
61+
raise NanoKVMSSHAuthenticationError(
62+
f"SSH authentication failed: {e}"
63+
) from e
64+
except (paramiko.SSHException, paramiko.BadHostKeyException, OSError) as e:
65+
raise NanoKVMSSHAuthenticationError(f"SSH connection failed: {e}") from e
66+
67+
async def disconnect(self) -> None:
68+
"""Close SSH connection."""
69+
if self.ssh_client:
70+
self.ssh_client.close()
71+
self.ssh_client = None
72+
73+
async def run_command(self, command: str, timeout: int = 30) -> str:
74+
"""Run a command via SSH and return output."""
75+
if not self.ssh_client:
76+
raise NanoKVMSSHNotConnectedError(
77+
"SSH not connected, call authenticate first"
78+
)
79+
loop = asyncio.get_running_loop()
80+
try:
81+
output, error = await asyncio.wait_for(
82+
loop.run_in_executor(None, self._exec_command_sync, command),
83+
timeout=timeout,
84+
)
85+
if error:
86+
raise NanoKVMSSHCommandError(f"SSH command error: {error}")
87+
return output.strip()
88+
except asyncio.TimeoutError:
89+
raise NanoKVMSSHCommandError(
90+
f"SSH command timed out after {timeout} seconds"
91+
) from None
92+
93+
def _exec_command_sync(self, command: str) -> tuple[str, str]:
94+
"""Synchronous SSH command execution."""
95+
assert self.ssh_client is not None # Should be set after authenticate()
96+
stdin, stdout, stderr = self.ssh_client.exec_command(command)
97+
output = stdout.read().decode("utf-8")
98+
error = stderr.read().decode("utf-8")
99+
return output, error

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"yarl",
2020
"pillow",
2121
"pydantic",
22+
"paramiko",
2223
]
2324

2425
[tool.setuptools.packages.find]

0 commit comments

Comments
 (0)