2024-10-17 22:11:10 +02:00
|
|
|
import asyncio
|
2024-10-18 10:37:41 +02:00
|
|
|
from typing import Optional
|
2024-10-17 22:11:10 +02:00
|
|
|
|
2024-10-19 11:26:43 +02:00
|
|
|
import paramiko
|
|
|
|
|
2024-10-17 22:11:10 +02:00
|
|
|
from custom_components.easy_computer_manager import LOGGER
|
2024-10-19 11:26:43 +02:00
|
|
|
from custom_components.easy_computer_manager.computer import CommandOutput
|
2024-10-17 22:11:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
class SSHClient:
|
2024-10-19 12:02:45 +02:00
|
|
|
def __init__(self, host: str, username: str, password: Optional[str] = None, port: int = 22):
|
2024-10-17 22:11:10 +02:00
|
|
|
self.host = host
|
|
|
|
self.username = username
|
|
|
|
self._password = password
|
|
|
|
self.port = port
|
2024-10-19 12:02:45 +02:00
|
|
|
self._connection: Optional[paramiko.SSHClient] = None
|
|
|
|
|
|
|
|
async def __aenter__(self):
|
|
|
|
await self.connect()
|
|
|
|
return self
|
|
|
|
|
|
|
|
async def __aexit__(self, exc_type, exc_value, traceback):
|
|
|
|
self.disconnect()
|
2024-10-17 22:11:10 +02:00
|
|
|
|
|
|
|
async def connect(self, retried: bool = False, computer: Optional['Computer'] = None) -> None:
|
|
|
|
"""Open an SSH connection using Paramiko asynchronously."""
|
2024-10-19 12:02:45 +02:00
|
|
|
if self.is_connection_alive():
|
|
|
|
LOGGER.debug(f"Connection to {self.host} is already active.")
|
|
|
|
return
|
2024-10-17 22:11:10 +02:00
|
|
|
|
2024-10-19 12:02:45 +02:00
|
|
|
self.disconnect() # Ensure any previous connection is closed
|
2024-10-17 22:11:10 +02:00
|
|
|
|
2024-10-19 12:02:45 +02:00
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
client = paramiko.SSHClient()
|
2024-10-17 22:11:10 +02:00
|
|
|
|
2024-10-19 12:02:45 +02:00
|
|
|
# Set missing host key policy to automatically accept unknown host keys
|
|
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
2024-10-17 22:11:10 +02:00
|
|
|
|
2024-10-19 12:02:45 +02:00
|
|
|
try:
|
2024-10-17 22:11:10 +02:00
|
|
|
# Offload the blocking connect call to a thread
|
|
|
|
await loop.run_in_executor(None, self._blocking_connect, client)
|
|
|
|
self._connection = client
|
2024-10-19 12:02:45 +02:00
|
|
|
LOGGER.debug(f"Connected to {self.host}")
|
2024-10-17 22:11:10 +02:00
|
|
|
|
|
|
|
except (OSError, paramiko.SSHException) as exc:
|
2024-10-19 12:02:45 +02:00
|
|
|
LOGGER.debug(f"Failed to connect to {self.host}: {exc}")
|
|
|
|
if not retried:
|
|
|
|
LOGGER.debug(f"Retrying connection to {self.host}...")
|
|
|
|
await self.connect(retried=True) # Retry only once
|
|
|
|
|
2024-10-17 22:11:10 +02:00
|
|
|
finally:
|
2024-10-19 12:02:45 +02:00
|
|
|
if computer is not None and hasattr(computer, "initialized"):
|
|
|
|
computer.initialized = True
|
2024-10-17 22:11:10 +02:00
|
|
|
|
|
|
|
def disconnect(self) -> None:
|
|
|
|
"""Close the SSH connection."""
|
2024-10-19 12:02:45 +02:00
|
|
|
if self._connection:
|
2024-10-17 22:11:10 +02:00
|
|
|
self._connection.close()
|
2024-10-19 12:02:45 +02:00
|
|
|
LOGGER.debug(f"Disconnected from {self.host}")
|
|
|
|
self._connection = None
|
2024-10-17 22:11:10 +02:00
|
|
|
|
2024-10-19 12:02:45 +02:00
|
|
|
def _blocking_connect(self, client: paramiko.SSHClient):
|
2024-10-17 22:11:10 +02:00
|
|
|
"""Perform the blocking SSH connection using Paramiko."""
|
|
|
|
client.connect(
|
2024-10-19 12:02:45 +02:00
|
|
|
hostname=self.host,
|
2024-10-17 22:11:10 +02:00
|
|
|
username=self.username,
|
|
|
|
password=self._password,
|
2024-10-19 12:02:45 +02:00
|
|
|
port=self.port,
|
|
|
|
look_for_keys=False, # Set this to True if using private keys
|
|
|
|
allow_agent=False
|
2024-10-17 22:11:10 +02:00
|
|
|
)
|
|
|
|
|
2024-10-19 11:26:43 +02:00
|
|
|
async def execute_command(self, command: str) -> CommandOutput:
|
2024-10-17 22:11:10 +02:00
|
|
|
"""Execute a command on the SSH server asynchronously."""
|
2024-10-19 12:02:45 +02:00
|
|
|
if not self.is_connection_alive():
|
|
|
|
LOGGER.debug(f"Connection to {self.host} is not alive. Reconnecting...")
|
|
|
|
await self.connect()
|
|
|
|
|
2024-10-17 22:11:10 +02:00
|
|
|
try:
|
2024-10-19 12:02:45 +02:00
|
|
|
# Offload command execution to avoid blocking
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
stdin, stdout, stderr = await loop.run_in_executor(None, self._connection.exec_command, command)
|
2024-10-17 22:11:10 +02:00
|
|
|
|
2024-10-19 12:02:45 +02:00
|
|
|
exit_status = stdout.channel.recv_exit_status()
|
2024-10-19 11:26:43 +02:00
|
|
|
return CommandOutput(command, exit_status, stdout.read().decode(), stderr.read().decode())
|
|
|
|
|
2024-10-18 10:37:41 +02:00
|
|
|
except (paramiko.SSHException, EOFError) as exc:
|
2024-10-19 12:02:45 +02:00
|
|
|
LOGGER.error(f"Failed to execute command on {self.host}: {exc}")
|
2024-10-19 11:26:43 +02:00
|
|
|
return CommandOutput(command, -1, "", "")
|
2024-10-17 22:11:10 +02:00
|
|
|
|
|
|
|
def is_connection_alive(self) -> bool:
|
2024-10-19 12:02:45 +02:00
|
|
|
"""Check if the SSH connection is still alive."""
|
2024-10-18 10:37:41 +02:00
|
|
|
if self._connection is None:
|
|
|
|
return False
|
2024-10-17 22:11:10 +02:00
|
|
|
|
2024-10-18 10:37:41 +02:00
|
|
|
try:
|
2024-10-17 22:11:10 +02:00
|
|
|
transport = self._connection.get_transport()
|
|
|
|
transport.send_ignore()
|
2024-10-19 11:26:43 +02:00
|
|
|
|
|
|
|
self._connection.exec_command('ls', timeout=1)
|
2024-10-17 22:11:10 +02:00
|
|
|
return True
|
2024-10-19 11:26:43 +02:00
|
|
|
|
2024-10-19 12:02:45 +02:00
|
|
|
except Exception as e:
|
2024-10-17 22:11:10 +02:00
|
|
|
return False
|