"""Module for connections.""" import abc import asyncio import logging from asyncio import IncompleteReadError, LimitOverrunError, TimeoutError from asyncio.streams import StreamReader, StreamWriter from math import floor from typing import List, Optional from asyncssh import SSHClientConnection, connect, set_log_level _LOGGER = logging.getLogger(__name__) _PATH_EXPORT_COMMAND: str = "PATH=$PATH:/bin:/usr/sbin:/sbin" set_log_level("WARNING") class _CommandException(Exception): """Protected command exception.""" class _BaseConnection(abc.ABC): def __init__( self, host: str, port: int, username: Optional[str] = None, password: Optional[str] = None, ): self._host: str = host self._port: int = port self._username: Optional[str] = username self._password: Optional[str] = password self._io_lock = asyncio.Lock() @property def description(self) -> str: """Description of the connection.""" ret = f"{self._host}:{self._port}" if self._username: ret = f"{self._username}@{ret}" return ret async def async_run_command( self, command: str, retry: bool = True ) -> List[str]: """Call a command using the connection.""" async with self._io_lock: if not self.is_connected: await self.async_connect() try: return await self._async_call_command(command) except _CommandException as ex: _LOGGER.exception(ex) # The command failed if retry: _LOGGER.debug(f"Retrying command: {command}") return await self._async_call_command(command) return [] async def async_connect(self) -> None: if self.is_connected: _LOGGER.debug( f"Connection already established to: {self.description}" ) return await self._async_connect() async def async_disconnect(self) -> None: """Disconnect the client.""" async with self._io_lock: self._disconnect() @abc.abstractmethod async def _async_call_command(self, command: str) -> List[str]: """Call the command.""" @abc.abstractmethod async def _async_connect(self) -> None: """Establish a connection.""" @abc.abstractmethod def _disconnect(self) -> None: """Disconnect.""" @property @abc.abstractmethod def is_connected(self) -> bool: """Do we have a connection.""" pass def create_connection( use_telnet: bool, host: str, port: Optional[int], username: Optional[str], password: Optional[str], ssh_key: Optional[str], ) -> _BaseConnection: """Create a connection to the router.""" if use_telnet: return TelnetConnection( host=host, port=port, username=username, password=password ) else: return SshConnection( host=host, port=port, username=username, password=password, ssh_key=ssh_key, ) class SshConnection(_BaseConnection): """Maintains an SSH connection to an ASUS-WRT router.""" def __init__( self, host: str, port: Optional[int] = None, username: Optional[str] = None, password: Optional[str] = None, ssh_key: Optional[str] = None, passphrase: Optional[str] = None, ): """Initialize the SSH connection properties.""" super().__init__(host, port or 22, username, password) self._ssh_key = ssh_key self._client: Optional[SSHClientConnection] = None self._lock = asyncio.Lock() self._passphrase: Optional[str] = passphrase async def _async_call_command(self, command: str) -> List[str]: """ Run commands through an SSH connection. Connect to the SSH server if not currently connected, otherwise use the existing connection. """ if not self._client: raise ConnectionError("Lost connection to router") async with self._lock: result = await asyncio.wait_for( self._client.run("%s && %s" % (_PATH_EXPORT_COMMAND, command)), 9, ) return list(str(result.stdout).split("\n")) @property def is_connected(self) -> bool: """Do we have a connection.""" return self._client is not None async def _async_connect(self) -> None: """Fetch the client or creates a new one.""" kwargs = { "username": self._username, "client_keys": [self._ssh_key] if self._ssh_key else None, "port": self._port, "password": self._password, "passphrase": self._passphrase, "known_hosts": None, "server_host_key_algs": [ "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-ed25519", "ssh-ed448", ], } async with self._lock: if self._client: _LOGGER.debug( "reconnecting; old connection had local port %d", self._client._local_port if self._client else "Unknown", ) self._disconnect() else: _LOGGER.debug("reconnecting; no old connection existed") self._client = await connect(self._host, **kwargs) _LOGGER.debug( "reconnected; new connection has local port %d", self._client._local_port if self._client else "Unknown", ) def _disconnect(self) -> None: if self._client: self._client.close() self._client = None class TelnetConnection(_BaseConnection): """Maintains a Telnet connection to an ASUS-WRT router.""" def __init__( self, host: str, port: Optional[int], username: Optional[str], password: Optional[str], ): """Initialize the Telnet connection properties.""" super().__init__(host, port or 23, username, password) self._reader: Optional[StreamReader] = None self._writer: Optional[StreamWriter] = None self._prompt_string = "".encode("ascii") self._linebreak: Optional[float] = None async def _async_call_command(self, command: str) -> List[str]: """Run a command through a Telnet connection.""" try: if not self.is_connected: await self._async_connect() if self._linebreak is None: self._linebreak = await self._async_linebreak() if not self._writer or not self._reader: raise _CommandException # Let's add the path and send the command full_cmd = f"{_PATH_EXPORT_COMMAND} && {command}" self._writer.write((full_cmd + "\n").encode("ascii")) # And read back the data till the prompt string data = await asyncio.wait_for( self._reader.readuntil(self._prompt_string), 9 ) except (BrokenPipeError, LimitOverrunError, IncompleteReadError) as ex: # Writing has failed, Let's close and retry if necessary _LOGGER.warning("connection is lost to host.") self._disconnect() raise _CommandException from ex except TimeoutError as ex: _LOGGER.error("Host timeout.") self._disconnect() raise _CommandException from ex # Let's process the received data data_list = data.split(b"\n") # Let's find the number of elements the cmd takes cmd_len = len(self._prompt_string) + len(full_cmd) # We have to do floor + 1 to handle the infinite case correct start_split = floor(cmd_len / self._linebreak) + 1 return list(line.decode("utf-8") for line in data_list[start_split:-1]) async def async_connect(self) -> None: """Connect to the ASUS-WRT Telnet server.""" async with self._io_lock: await self._async_connect() async def _async_connect(self) -> None: if self.is_connected: self._disconnect() self._reader, self._writer = await asyncio.open_connection( self._host, self._port ) # Process the login # Enter the Username try: await asyncio.wait_for(self._reader.readuntil(b"login: "), 9) except asyncio.IncompleteReadError: _LOGGER.error( "Unable to read from router on %s:%s" % (self._host, self._port) ) return except TimeoutError: _LOGGER.error("Host timeout.") self._disconnect() self._writer.write((self._username or "" + "\n").encode("ascii")) # Enter the password await self._reader.readuntil(b"Password: ") self._writer.write((self._password or "" + "\n").encode("ascii")) # Now we can determine the prompt string for the commands. self._prompt_string = (await self._reader.readuntil(b"#")).split( b"\n" )[-1] async def _async_linebreak(self) -> float: """ Get linebreak. Telnet or asyncio seems to be adding linebreaks due to terminal size. Try to determine here what the column number is. """ # Let's determine if any linebreaks are added # Write some arbitrary long string. if not self._writer or not self._reader: raise _CommandException self._writer.write((" " * 200 + "\n").encode("ascii")) input_bytes = await self._reader.readuntil(self._prompt_string) return self._determine_linebreak(input_bytes) def _determine_linebreak(self, input_bytes: bytes) -> float: # Let's convert the data to the expected format data = input_bytes.decode("utf-8").replace("\r", "").split("\n") if len(data) == 1: # There was no split, so assume infinite linebreak = float("inf") else: # The linebreak is the length of the prompt string + the first line linebreak = len(self._prompt_string) + len(data[0]) if len(data) > 2: # We can do a quick sanity check, as there are more linebreaks if len(data[1]) != linebreak: _LOGGER.warning( f"Inconsistent linebreaks {len(data[1])} != " f"{linebreak}" ) return linebreak @property def is_connected(self) -> bool: """Do we have a connection.""" return self._reader is not None and self._writer is not None def _disconnect(self) -> None: """ Disconnect the connection. Ensure that the caller holds the io_lock. """ if self._writer: self._writer.close() self._writer = None self._reader = None self._linebreak = None