1840b1e01SJuraj Linkeš# SPDX-License-Identifier: BSD-3-Clause 2840b1e01SJuraj Linkeš# Copyright(c) 2023 University of New Hampshire 3840b1e01SJuraj Linkeš 4840b1e01SJuraj Linkeš"""Handler for an SSH session dedicated to interactive shells.""" 5840b1e01SJuraj Linkeš 6840b1e01SJuraj Linkešimport socket 7840b1e01SJuraj Linkešimport traceback 8*42274463SJuraj Linkešfrom typing import Union 9840b1e01SJuraj Linkeš 10282688eaSLuca Vizzarrofrom paramiko import AutoAddPolicy, SSHClient, Transport # type: ignore[import-untyped] 11282688eaSLuca Vizzarrofrom paramiko.ssh_exception import ( # type: ignore[import-untyped] 12840b1e01SJuraj Linkeš AuthenticationException, 13840b1e01SJuraj Linkeš BadHostKeyException, 14840b1e01SJuraj Linkeš NoValidConnectionsError, 15840b1e01SJuraj Linkeš SSHException, 16840b1e01SJuraj Linkeš) 17840b1e01SJuraj Linkeš 18840b1e01SJuraj Linkešfrom framework.config import NodeConfiguration 19840b1e01SJuraj Linkešfrom framework.exception import SSHConnectionError 2004f5a5a6SJuraj Linkešfrom framework.logger import DTSLogger 21840b1e01SJuraj Linkeš 22840b1e01SJuraj Linkeš 23840b1e01SJuraj Linkešclass InteractiveRemoteSession: 24840b1e01SJuraj Linkeš """SSH connection dedicated to interactive applications. 25840b1e01SJuraj Linkeš 266ef07151SJuraj Linkeš The connection is created using `paramiko <https://docs.paramiko.org/en/latest/>`_ 276ef07151SJuraj Linkeš and is a persistent connection to the host. This class defines the methods for connecting 286ef07151SJuraj Linkeš to the node and configures the connection to send "keep alive" packets every 30 seconds. 296ef07151SJuraj Linkeš Because paramiko attempts to use SSH keys to establish a connection first, providing 306ef07151SJuraj Linkeš a password is optional. This session is utilized by InteractiveShells 316ef07151SJuraj Linkeš and cannot be interacted with directly. 32840b1e01SJuraj Linkeš 33840b1e01SJuraj Linkeš Attributes: 346ef07151SJuraj Linkeš hostname: The hostname that will be used to initialize a connection to the node. 356ef07151SJuraj Linkeš ip: A subsection of `hostname` that removes the port for the connection if there 36840b1e01SJuraj Linkeš is one. If there is no port, this will be the same as hostname. 376ef07151SJuraj Linkeš port: Port to use for the ssh connection. This will be extracted from `hostname` 386ef07151SJuraj Linkeš if there is a port included, otherwise it will default to ``22``. 39840b1e01SJuraj Linkeš username: User to connect to the node with. 40840b1e01SJuraj Linkeš password: Password of the user connecting to the host. This will default to an 41840b1e01SJuraj Linkeš empty string if a password is not provided. 426ef07151SJuraj Linkeš session: The underlying paramiko connection. 43840b1e01SJuraj Linkeš 44840b1e01SJuraj Linkeš Raises: 45840b1e01SJuraj Linkeš SSHConnectionError: There is an error creating the SSH connection. 46840b1e01SJuraj Linkeš """ 47840b1e01SJuraj Linkeš 48840b1e01SJuraj Linkeš hostname: str 49840b1e01SJuraj Linkeš ip: str 50840b1e01SJuraj Linkeš port: int 51840b1e01SJuraj Linkeš username: str 52840b1e01SJuraj Linkeš password: str 53840b1e01SJuraj Linkeš session: SSHClient 5404f5a5a6SJuraj Linkeš _logger: DTSLogger 55840b1e01SJuraj Linkeš _node_config: NodeConfiguration 56*42274463SJuraj Linkeš _transport: Union[Transport, None] 57840b1e01SJuraj Linkeš 5804f5a5a6SJuraj Linkeš def __init__(self, node_config: NodeConfiguration, logger: DTSLogger) -> None: 596ef07151SJuraj Linkeš """Connect to the node during initialization. 606ef07151SJuraj Linkeš 616ef07151SJuraj Linkeš Args: 626ef07151SJuraj Linkeš node_config: The test run configuration of the node to connect to. 636ef07151SJuraj Linkeš logger: The logger instance this session will use. 646ef07151SJuraj Linkeš """ 65840b1e01SJuraj Linkeš self._node_config = node_config 666ef07151SJuraj Linkeš self._logger = logger 67840b1e01SJuraj Linkeš self.hostname = node_config.hostname 68840b1e01SJuraj Linkeš self.username = node_config.user 69840b1e01SJuraj Linkeš self.password = node_config.password if node_config.password else "" 70840b1e01SJuraj Linkeš port = "22" 71840b1e01SJuraj Linkeš self.ip = node_config.hostname 72840b1e01SJuraj Linkeš if ":" in node_config.hostname: 73840b1e01SJuraj Linkeš self.ip, port = node_config.hostname.split(":") 74840b1e01SJuraj Linkeš self.port = int(port) 75840b1e01SJuraj Linkeš self._logger.info( 76840b1e01SJuraj Linkeš f"Initializing interactive connection for {self.username}@{self.hostname}" 77840b1e01SJuraj Linkeš ) 78840b1e01SJuraj Linkeš self._connect() 79840b1e01SJuraj Linkeš self._logger.info(f"Interactive connection successful for {self.username}@{self.hostname}") 80840b1e01SJuraj Linkeš 81840b1e01SJuraj Linkeš def _connect(self) -> None: 82840b1e01SJuraj Linkeš """Establish a connection to the node. 83840b1e01SJuraj Linkeš 84840b1e01SJuraj Linkeš Connection attempts can be retried up to 10 times if certain errors are 85840b1e01SJuraj Linkeš encountered (NoValidConnectionsError, socket.error, SSHException). If a 86840b1e01SJuraj Linkeš connection is made, a 30 second "keep alive" interval will be set on the 87840b1e01SJuraj Linkeš session. 88840b1e01SJuraj Linkeš 89840b1e01SJuraj Linkeš Raises: 90840b1e01SJuraj Linkeš SSHConnectionError: Connection cannot be established. 91840b1e01SJuraj Linkeš """ 92840b1e01SJuraj Linkeš client = SSHClient() 93840b1e01SJuraj Linkeš client.set_missing_host_key_policy(AutoAddPolicy) 94840b1e01SJuraj Linkeš self.session = client 95840b1e01SJuraj Linkeš retry_attempts = 10 96840b1e01SJuraj Linkeš for retry_attempt in range(retry_attempts): 97840b1e01SJuraj Linkeš try: 98840b1e01SJuraj Linkeš client.connect( 99840b1e01SJuraj Linkeš self.ip, 100840b1e01SJuraj Linkeš username=self.username, 101840b1e01SJuraj Linkeš port=self.port, 102840b1e01SJuraj Linkeš password=self.password, 103840b1e01SJuraj Linkeš timeout=20 if self.port else 10, 104840b1e01SJuraj Linkeš ) 105840b1e01SJuraj Linkeš except (TypeError, BadHostKeyException, AuthenticationException) as e: 106840b1e01SJuraj Linkeš self._logger.exception(e) 107840b1e01SJuraj Linkeš raise SSHConnectionError(self.hostname) from e 108840b1e01SJuraj Linkeš except (NoValidConnectionsError, socket.error, SSHException) as e: 109840b1e01SJuraj Linkeš self._logger.debug(traceback.format_exc()) 110840b1e01SJuraj Linkeš self._logger.warning(e) 111840b1e01SJuraj Linkeš self._logger.info( 112840b1e01SJuraj Linkeš f"Retrying interactive session connection: retry number {retry_attempt +1}" 113840b1e01SJuraj Linkeš ) 114840b1e01SJuraj Linkeš else: 115840b1e01SJuraj Linkeš break 116840b1e01SJuraj Linkeš else: 117840b1e01SJuraj Linkeš raise SSHConnectionError(self.hostname) 118840b1e01SJuraj Linkeš # Interactive sessions are used on an "as needed" basis so we have 119840b1e01SJuraj Linkeš # to set a keepalive 120840b1e01SJuraj Linkeš self._transport = self.session.get_transport() 121840b1e01SJuraj Linkeš if self._transport is not None: 122840b1e01SJuraj Linkeš self._transport.set_keepalive(30) 123