def test_socket_open_failure_connection_refused(): sock = Socket("localhost", 2222, 1) with pytest.raises(ConnectionRefusedError) as e: sock.socket_open() assert str( e.value ) == "Connection refused trying to open socket to localhost on port 2222"
def __init__( self, host: str, port: int = -1, auth_username: str = "", auth_private_key: str = "", auth_password: str = "", auth_strict_key: bool = True, timeout_socket: int = 5, timeout_transport: int = 5, timeout_exit: bool = True, ssh_config_file: str = "", ssh_known_hosts_file: str = "", ) -> None: """ SSH2Transport Object Inherit from Transport ABC SSH2Transport <- Transport (ABC) Args: host: host ip/name to connect to port: port to connect to auth_username: username for authentication auth_private_key: path to private key for authentication auth_password: password for authentication auth_strict_key: True/False to enforce strict key checking (default is True) timeout_socket: timeout for establishing socket in seconds timeout_transport: timeout for ssh2 transport in seconds timeout_exit: True/False close transport if timeout encountered ssh_config_file: string to path for ssh config file ssh_known_hosts_file: string to path for ssh known hosts file Returns: N/A # noqa: DAR202 Raises: N/A """ cfg_port, cfg_user, cfg_private_key = self._process_ssh_config(host, ssh_config_file) if port == -1: port = cfg_port or 22 super().__init__( host, port, timeout_socket, timeout_transport, timeout_exit, ) self.auth_username: str = auth_username or cfg_user self.auth_private_key: str = auth_private_key or cfg_private_key self.auth_password: str = auth_password self.auth_strict_key: bool = auth_strict_key self.ssh_known_hosts_file: str = ssh_known_hosts_file self.session: Session = None self.channel: Channel = None self.socket = Socket(host=self.host, port=self.port, timeout=self.timeout_socket)
class SSH2Transport(Transport): def __init__( self, host: str, port: int = -1, auth_username: str = "", auth_private_key: str = "", auth_password: str = "", auth_strict_key: bool = True, timeout_socket: int = 5, timeout_transport: int = 5, timeout_exit: bool = True, ssh_config_file: str = "", ssh_known_hosts_file: str = "", ) -> None: """ SSH2Transport Object Inherit from Transport ABC SSH2Transport <- Transport (ABC) Args: host: host ip/name to connect to port: port to connect to auth_username: username for authentication auth_private_key: path to private key for authentication auth_password: password for authentication auth_strict_key: True/False to enforce strict key checking (default is True) timeout_socket: timeout for establishing socket in seconds timeout_transport: timeout for ssh2 transport in seconds timeout_exit: True/False close transport if timeout encountered ssh_config_file: string to path for ssh config file ssh_known_hosts_file: string to path for ssh known hosts file Returns: N/A # noqa: DAR202 Raises: N/A """ cfg_port, cfg_user, cfg_private_key = self._process_ssh_config(host, ssh_config_file) if port == -1: port = cfg_port or 22 super().__init__( host, port, timeout_socket, timeout_transport, timeout_exit, ) self.auth_username: str = auth_username or cfg_user self.auth_private_key: str = auth_private_key or cfg_private_key self.auth_password: str = auth_password self.auth_strict_key: bool = auth_strict_key self.ssh_known_hosts_file: str = ssh_known_hosts_file self.session: Session = None self.channel: Channel = None self.socket = Socket(host=self.host, port=self.port, timeout=self.timeout_socket) @staticmethod def _process_ssh_config(host: str, ssh_config_file: str) -> Tuple[Optional[int], str, str]: """ Method to parse ssh config file In the future this may move to be a "helper" function as it should be very similar between paramiko and and ssh2-python... for now it can be a static method as there may be varying supported args between the two transport drivers. Args: host: host to lookup in ssh config file ssh_config_file: string path to ssh config file; passed down from `Scrape`, or the `NetworkDriver` or subclasses of it, in most cases. Returns: Tuple: port to use for ssh, username to use for ssh, identity file (private key) to use for ssh auth Raises: N/A """ ssh = SSHConfig(ssh_config_file) host_config = ssh.lookup(host) return host_config.port, host_config.user or "", host_config.identity_file or "" def open(self) -> None: """ Parent method to open session, authenticate and acquire shell Args: N/A Returns: N/A # noqa: DAR202 Raises: Exception: if socket handshake fails ScrapliAuthenticationFailed: if all authentication means fail """ if not self.socket.socket_isalive(): self.socket.socket_open() self.session = Session() self.set_timeout(self.timeout_transport) try: self.session.handshake(self.socket.sock) except Exception as exc: LOG.critical( f"Failed to complete handshake with host {self.host}; " f"Exception: {exc}" ) raise exc if self.auth_strict_key: LOG.debug(f"Attempting to validate {self.host} public key") self._verify_key() LOG.debug(f"Session to host {self.host} opened") self._authenticate() if not self._isauthenticated(): msg = f"Authentication to host {self.host} failed" LOG.critical(msg) raise ScrapliAuthenticationFailed(msg) self._open_channel() def _verify_key(self) -> None: """ Verify target host public key, raise exception if invalid/unknown Args: N/A Returns: N/A # noqa: DAR202 Raises: KeyVerificationFailed: if public key verification fails """ known_hosts = SSHKnownHosts(self.ssh_known_hosts_file) if self.host not in known_hosts.hosts.keys(): raise KeyVerificationFailed(f"{self.host} not in known_hosts!") remote_server_key_info = self.session.hostkey() encoded_remote_server_key = remote_server_key_info[0] raw_remote_public_key = base64.encodebytes(encoded_remote_server_key) remote_public_key = raw_remote_public_key.replace(b"\n", b"").decode() if known_hosts.hosts[self.host]["public_key"] != remote_public_key: raise KeyVerificationFailed( f"{self.host} in known_hosts but public key does not match!" ) def _authenticate(self) -> None: """ Parent method to try all means of authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: ScrapliAuthenticationFailed: if auth fails """ if self.auth_private_key: self._authenticate_public_key() if self._isauthenticated(): LOG.debug(f"Authenticated to host {self.host} with public key auth") return if not self.auth_password or not self.auth_username: msg = ( f"Failed to authenticate to host {self.host} with private key " f"`{self.auth_private_key}`. Unable to continue authentication, " "missing username, password, or both." ) LOG.critical(msg) raise ScrapliAuthenticationFailed(msg) self._authenticate_password() if self._isauthenticated(): LOG.debug(f"Authenticated to host {self.host} with password") return self._authenticate_keyboard_interactive() if self._isauthenticated(): LOG.debug(f"Authenticated to host {self.host} with keyboard interactive") def _authenticate_public_key(self) -> None: """ Attempt to authenticate with public key authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ try: self.session.userauth_publickey_fromfile( self.auth_username, self.auth_private_key.encode() ) except AuthenticationError as exc: LOG.critical( f"Public key authentication with host {self.host} failed. Exception: {exc}." ) except SSH2Error as exc: LOG.critical( "Unknown error occurred during public key authentication with host " f"{self.host}; Exception: {exc}" ) def _authenticate_password(self) -> None: """ Attempt to authenticate with password authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: Exception: if unknown (i.e. not auth failed) exception occurs """ try: self.session.userauth_password(self.auth_username, self.auth_password) except AuthenticationError: LOG.critical( f"Password authentication with host {self.host} failed. Exception: " f"`AuthenticationError`." ) except Exception as exc: LOG.critical( "Unknown error occurred during password authentication with host " f"{self.host}; Exception: {exc}" ) raise exc def _authenticate_keyboard_interactive(self) -> None: """ Attempt to authenticate with keyboard interactive authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: Exception: if unknown (i.e. not auth failed) exception occurs """ try: self.session.userauth_keyboardinteractive( # pylint: disable=C0415 self.auth_username, self.auth_password ) except AttributeError as exc: LOG.critical( "Keyboard interactive authentication not supported in your ssh2-python version. " f"Exception: {exc}" ) except AuthenticationError: LOG.critical( f"Keyboard interactive authentication with host {self.host} failed. " f"Exception: `AuthenticationError`." ) except Exception as exc: LOG.critical( "Unknown error occurred during keyboard interactive authentication with host " f"{self.host}; Exception: {exc}" ) raise exc def _isauthenticated(self) -> bool: """ Check if session is authenticated Args: N/A Returns: bool: True if authenticated, else False Raises: N/A """ authenticated: bool = self.session.userauth_authenticated() return authenticated def _open_channel(self) -> None: """ Open channel, acquire pty, request interactive shell Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.channel = self.session.open_session() self.channel.pty() self.channel.shell() LOG.debug(f"Channel to host {self.host} opened") def close(self) -> None: """ Close session and socket Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.channel.close() LOG.debug(f"Channel to host {self.host} closed") self.socket.socket_close() def isalive(self) -> bool: """ Check if socket is alive and session is authenticated Args: N/A Returns: bool: True if socket is alive and session authenticated, else False Raises: N/A """ if self.socket.socket_isalive() and not self.channel.eof() and self._isauthenticated(): return True return False def read(self) -> bytes: """ Read data from the channel Args: N/A Returns: bytes: bytes output as read from channel Raises: N/A """ output: bytes _, output = self.channel.read(65535) return output def write(self, channel_input: str) -> None: """ Write data to the channel Args: channel_input: string to send to channel Returns: N/A # noqa: DAR202 Raises: N/A """ self.channel.write(channel_input) def set_timeout(self, timeout: int) -> None: """ Set session timeout Args: timeout: timeout in seconds Returns: N/A # noqa: DAR202 Raises: N/A """ # ssh2-python expects timeout in milliseconds self.session.set_timeout(timeout * 1000)
def test_socket_open_success(): sock = Socket("localhost", 22, 1) sock.socket_open() assert sock.socket_isalive() is True
def test__bool(): sock = Socket("localhost", 22, 1) assert bool(sock) is False
def test__repr(): sock = Socket("localhost", 22, 1) assert repr( sock ) == "Socket {'host': 'localhost', 'port': 22, 'timeout': 1, 'sock': None}"
def test__str(): sock = Socket("localhost", 22, 1) assert str(sock) == "Socket Object for host localhost"
def test_socket_isalive_false(): sock = Socket("localhost", 22, 1) assert sock.socket_isalive() is False
def test_socket_open_failure_timeout(): sock = Socket("240.0.0.1", 22, 0.1) with pytest.raises(ScrapliTimeout) as e: sock.socket_open() assert str( e.value) == "Timed out trying to open socket to 240.0.0.1 on port 22"
def __init__( self, host: str, port: int = -1, auth_username: str = "", auth_private_key: str = "", auth_password: str = "", auth_strict_key: bool = True, timeout_socket: int = 5, timeout_transport: int = 5, timeout_exit: bool = True, keepalive: bool = False, keepalive_interval: int = 30, keepalive_type: str = "", keepalive_pattern: str = "\005", ssh_config_file: str = "", ssh_known_hosts_file: str = "", ) -> None: """ MikoTransport Object Inherit from Transport ABC MikoTransport <- Transport (ABC) Args: host: host ip/name to connect to port: port to connect to auth_username: username for authentication auth_private_key: path to private key for authentication auth_password: password for authentication auth_strict_key: True/False to enforce strict key checking (default is True) timeout_socket: timeout for establishing socket in seconds timeout_transport: timeout for ssh transport in seconds timeout_exit: True/False close transport if timeout encountered. If False and keepalives are in use, keepalives will prevent program from exiting so you should be sure to catch Timeout exceptions and handle them appropriately keepalive: whether or not to try to keep session alive keepalive_interval: interval to use for session keepalives keepalive_type: network|standard -- 'network' sends actual characters over the transport channel. This is useful for network-y type devices that may not support 'standard' keepalive mechanisms. 'standard' is not currently implemented w/ paramiko keepalive_pattern: pattern to send to keep network channel alive. Default is u'\005' which is equivalent to 'ctrl+e'. This pattern moves cursor to end of the line which should be an innocuous pattern. This will only be entered *if* a lock can be acquired. This is only applicable if using keepalives and if the keepalive type is 'network' ssh_config_file: string to path for ssh config file ssh_known_hosts_file: string to path for ssh known hosts file Returns: N/A # noqa: DAR202 Raises: MissingDependencies: if paramiko is not installed """ cfg_port, cfg_user, cfg_private_key = self._process_ssh_config(host, ssh_config_file) if port == -1: port = cfg_port or 22 super().__init__( host, port, timeout_socket, timeout_transport, timeout_exit, keepalive, keepalive_interval, keepalive_type, keepalive_pattern, ) self.auth_username: str = auth_username or cfg_user self.auth_private_key: str = auth_private_key or cfg_private_key self.auth_password: str = auth_password self.auth_strict_key: bool = auth_strict_key self.ssh_known_hosts_file: str = ssh_known_hosts_file self.session_lock: Lock = Lock() try: # import here so these are optional from paramiko import Transport as pTransport # pylint: disable=C0415 from paramiko import Channel # pylint: disable=C0415 from paramiko.ssh_exception import ( # pylint: disable=C0415 AuthenticationException, SSHException, ) self.lib_session = pTransport self.session: pTransport = None self.channel: Channel = None self.lib_auth_exception = AuthenticationException except ModuleNotFoundError as exc: err = f"Module '{exc.name}' not installed!" msg = f"***** {err} {'*' * (80 - len(err))}" fix = ( f"To resolve this issue, install '{exc.name}'. You can do this in one of the " "following ways:\n" "1: 'pip install -r requirements-paramiko.txt'\n" "2: 'pip install scrapli[paramiko]'" ) warning = "\n" + msg + "\n" + fix + "\n" + msg warnings.warn(warning) LOG.warning(warning) raise MissingDependencies self.socket = Socket(host=self.host, port=self.port, timeout=self.timeout_socket)
class MikoTransport(Transport): def __init__( self, host: str, port: int = -1, auth_username: str = "", auth_private_key: str = "", auth_password: str = "", auth_strict_key: bool = True, timeout_socket: int = 5, timeout_transport: int = 5, timeout_exit: bool = True, keepalive: bool = False, keepalive_interval: int = 30, keepalive_type: str = "", keepalive_pattern: str = "\005", ssh_config_file: str = "", ssh_known_hosts_file: str = "", ) -> None: """ MikoTransport Object Inherit from Transport ABC MikoTransport <- Transport (ABC) Args: host: host ip/name to connect to port: port to connect to auth_username: username for authentication auth_private_key: path to private key for authentication auth_password: password for authentication auth_strict_key: True/False to enforce strict key checking (default is True) timeout_socket: timeout for establishing socket in seconds timeout_transport: timeout for ssh transport in seconds timeout_exit: True/False close transport if timeout encountered. If False and keepalives are in use, keepalives will prevent program from exiting so you should be sure to catch Timeout exceptions and handle them appropriately keepalive: whether or not to try to keep session alive keepalive_interval: interval to use for session keepalives keepalive_type: network|standard -- 'network' sends actual characters over the transport channel. This is useful for network-y type devices that may not support 'standard' keepalive mechanisms. 'standard' is not currently implemented w/ paramiko keepalive_pattern: pattern to send to keep network channel alive. Default is u'\005' which is equivalent to 'ctrl+e'. This pattern moves cursor to end of the line which should be an innocuous pattern. This will only be entered *if* a lock can be acquired. This is only applicable if using keepalives and if the keepalive type is 'network' ssh_config_file: string to path for ssh config file ssh_known_hosts_file: string to path for ssh known hosts file Returns: N/A # noqa: DAR202 Raises: MissingDependencies: if paramiko is not installed """ cfg_port, cfg_user, cfg_private_key = self._process_ssh_config(host, ssh_config_file) if port == -1: port = cfg_port or 22 super().__init__( host, port, timeout_socket, timeout_transport, timeout_exit, keepalive, keepalive_interval, keepalive_type, keepalive_pattern, ) self.auth_username: str = auth_username or cfg_user self.auth_private_key: str = auth_private_key or cfg_private_key self.auth_password: str = auth_password self.auth_strict_key: bool = auth_strict_key self.ssh_known_hosts_file: str = ssh_known_hosts_file self.session_lock: Lock = Lock() try: # import here so these are optional from paramiko import Transport as pTransport # pylint: disable=C0415 from paramiko import Channel # pylint: disable=C0415 from paramiko.ssh_exception import ( # pylint: disable=C0415 AuthenticationException, SSHException, ) self.lib_session = pTransport self.session: pTransport = None self.channel: Channel = None self.lib_auth_exception = AuthenticationException except ModuleNotFoundError as exc: err = f"Module '{exc.name}' not installed!" msg = f"***** {err} {'*' * (80 - len(err))}" fix = ( f"To resolve this issue, install '{exc.name}'. You can do this in one of the " "following ways:\n" "1: 'pip install -r requirements-paramiko.txt'\n" "2: 'pip install scrapli[paramiko]'" ) warning = "\n" + msg + "\n" + fix + "\n" + msg warnings.warn(warning) LOG.warning(warning) raise MissingDependencies self.socket = Socket(host=self.host, port=self.port, timeout=self.timeout_socket) @staticmethod def _process_ssh_config(host: str, ssh_config_file: str) -> Tuple[Optional[int], str, str]: """ Method to parse ssh config file In the future this may move to be a 'helper' function as it should be very similar between paramiko and and ssh2-python... for now it can be a static method as there may be varying supported args between the two transport drivers. Args: host: host to lookup in ssh config file ssh_config_file: string path to ssh config file; passed down from `Scrape`, or the `NetworkDriver` or subclasses of it, in most cases. Returns: Tuple: port to use for ssh, username to use for ssh, identity file (private key) to use for ssh auth Raises: N/A """ ssh = SSHConfig(ssh_config_file) host_config = ssh.lookup(host) return host_config.port, host_config.user or "", host_config.identity_file or "" def open(self) -> None: """ Parent method to open session, authenticate and acquire shell Args: N/A Returns: N/A # noqa: DAR202 Raises: exc: if socket handshake fails ScrapliAuthenticationFailed: if all authentication means fail """ if not self.socket.socket_isalive(): self.socket.socket_open() self.session_lock.acquire() try: self.session = self.lib_session(self.socket.sock) self.session.start_client() except Exception as exc: LOG.critical(f"Failed to complete handshake with host {self.host}; Exception: {exc}") self.session_lock.release() raise exc if self.auth_strict_key: LOG.debug(f"Attempting to validate {self.host} public key") self._verify_key() self.authenticate() if not self.isauthenticated(): msg = f"Authentication to host {self.host} failed" LOG.critical(msg) self.session_lock.release() raise ScrapliAuthenticationFailed(msg) self._open_channel() self.session_lock.release() if self.keepalive: self._session_keepalive() def _verify_key(self) -> None: """ Verify target host public key, raise exception if invalid/unknown Args: N/A Returns: N/A # noqa: DAR202 Raises: KeyVerificationFailed: if host is not in known hosts KeyVerificationFailed: if host is in known hosts but public key does not match """ known_hosts = SSHKnownHosts(self.ssh_known_hosts_file) if self.host not in known_hosts.hosts.keys(): raise KeyVerificationFailed(f"{self.host} not in known_hosts!") remote_server_key = self.session.get_remote_server_key() remote_public_key = remote_server_key.get_base64() if known_hosts.hosts[self.host]["public_key"] != remote_public_key: raise KeyVerificationFailed( f"{self.host} in known_hosts but public key does not match!" ) def authenticate(self) -> None: """ Parent method to try all means of authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: ScrapliAuthenticationFailed: if authentication fails """ if self.auth_private_key: self._authenticate_public_key() if self.isauthenticated(): LOG.debug(f"Authenticated to host {self.host} with public key auth") return if not self.auth_password or not self.auth_username: msg = ( f"Public key authentication to host {self.host} failed. Missing username or" " password unable to attempt password authentication." ) LOG.critical(msg) raise ScrapliAuthenticationFailed(msg) self._authenticate_password() if self.isauthenticated(): LOG.debug(f"Authenticated to host {self.host} with password") def _authenticate_public_key(self) -> None: """ Attempt to authenticate with public key authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: exc: (really the paramiko auth exception) if auth exception occurs exc: (really the paramiko base exception) if unknown exception occurs """ try: # paramiko wants to see its key in a PKey object when crafting the paramiko connection # from Transport and Channel objects from paramiko.rsakey import RSAKey # pylint: disable=C0415 paramiko_key = RSAKey(filename=self.auth_private_key) self.session.auth_publickey(self.auth_username, paramiko_key) except self.lib_auth_exception as exc: LOG.critical( f"Public key authentication with host {self.host} failed. Exception: {exc}." ) except Exception as exc: # pylint: disable=W0703 LOG.critical( "Unknown error occurred during public key authentication with host " f"{self.host}; Exception: {exc}" ) def _authenticate_password(self) -> None: """ Attempt to authenticate with password authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: exc: if unknown (i.e. not auth failed) exception occurs """ try: self.session.auth_password(self.auth_username, self.auth_password) except self.lib_auth_exception as exc: LOG.critical( f"Password authentication with host {self.host} failed. Exception: {exc}." "\n\tNote: Paramiko automatically attempts both standard auth as well as keyboard " "interactive auth. Paramiko exception about bad auth type may be misleading!" ) except Exception as exc: LOG.critical( "Unknown error occurred during password authentication with host " f"{self.host}; Exception: {exc}" ) raise exc def isauthenticated(self) -> bool: """ Check if session is authenticated Args: N/A Returns: bool: True if authenticated, else False Raises: N/A """ authenticated: bool = self.session.is_authenticated() return authenticated def _open_channel(self) -> None: """ Open channel, acquire pty, request interactive shell Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.channel = self.session.open_session() self.set_timeout(self.timeout_transport) self.channel.get_pty() self.channel.invoke_shell() LOG.debug(f"Channel to host {self.host} opened") def close(self) -> None: """ Close session and socket Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.session_lock.acquire() self.channel.close() LOG.debug(f"Channel to host {self.host} closed") self.socket.socket_close() self.session_lock.release() def isalive(self) -> bool: """ Check if socket is alive and session is authenticated Args: N/A Returns: bool: True if socket is alive and session authenticated, else False Raises: N/A """ if self.socket.socket_isalive() and self.session.is_alive() and self.isauthenticated(): return True return False def read(self) -> bytes: """ Read data from the channel Args: N/A Returns: bytes: bytes output as read from channel Raises: N/A """ channel_read: bytes = self.channel.recv(65535) return channel_read def write(self, channel_input: str) -> None: """ Write data to the channel Args: channel_input: string to send to channel Returns: N/A # noqa: DAR202 Raises: N/A """ self.channel.send(channel_input) def set_timeout(self, timeout: Optional[int] = None) -> None: """ Set session timeout Args: timeout: timeout in seconds Returns: N/A # noqa: DAR202 Raises: N/A """ if isinstance(timeout, int): set_timeout = timeout else: set_timeout = self.timeout_transport self.channel.settimeout(set_timeout) def _keepalive_standard(self) -> None: """ Send 'out of band' (protocol level) keepalives to devices. Args: N/A Returns: N/A # noqa: DAR202 Raises: NotImplementedError: always, because this is not implemented for telnet """ raise NotImplementedError("No 'standard' keepalive mechanism for telnet.")
class SSH2Transport(Transport): def __init__( self, host: str, port: int = -1, auth_username: str = "", auth_private_key: str = "", auth_password: str = "", auth_strict_key: bool = True, timeout_socket: int = 5, timeout_transport: int = 5, timeout_exit: bool = True, keepalive: bool = False, keepalive_interval: int = 30, keepalive_type: str = "", keepalive_pattern: str = "\005", ssh_config_file: str = "", ssh_known_hosts_file: str = "", ) -> None: """ SSH2Transport Object Inherit from Transport ABC SSH2Transport <- Transport (ABC) Args: host: host ip/name to connect to port: port to connect to auth_username: username for authentication auth_private_key: path to private key for authentication auth_password: password for authentication auth_strict_key: True/False to enforce strict key checking (default is True) timeout_socket: timeout for establishing socket in seconds timeout_transport: timeout for ssh2 transport in seconds timeout_exit: True/False close transport if timeout encountered. If False and keepalives are in use, keepalives will prevent program from exiting so you should be sure to catch Timeout exceptions and handle them appropriately keepalive: whether or not to try to keep session alive keepalive_interval: interval to use for session keepalives keepalive_type: network|standard -- 'network' sends actual characters over the transport channel. This is useful for network-y type devices that may not support 'standard' keepalive mechanisms. 'standard' attempts to ssh2-python built in keepalive method (using standard openssh keepalive) keepalive_pattern: pattern to send to keep network channel alive. Default is u'\005' which is equivalent to 'ctrl+e'. This pattern moves cursor to end of the line which should be an innocuous pattern. This will only be entered *if* a lock can be acquired. This is only applicable if using keepalives and if the keepalive type is 'network' ssh_config_file: string to path for ssh config file ssh_known_hosts_file: string to path for ssh known hosts file Returns: N/A # noqa: DAR202 Raises: MissingDependencies: if ssh2-python is not installed """ cfg_port, cfg_user, cfg_private_key = self._process_ssh_config( host, ssh_config_file) if port == -1: port = cfg_port or 22 super().__init__( host, port, timeout_socket, timeout_transport, timeout_exit, keepalive, keepalive_interval, keepalive_type, keepalive_pattern, ) self.auth_username: str = auth_username or cfg_user self.auth_private_key: str = auth_private_key or cfg_private_key self.auth_password: str = auth_password self.auth_strict_key: bool = auth_strict_key self.ssh_known_hosts_file: str = ssh_known_hosts_file try: # import here so these are optional from ssh2.channel import Channel # pylint: disable=C0415 from ssh2.session import Session # pylint: disable=C0415 from ssh2.exceptions import AuthenticationError, SSH2Error # pylint: disable=C0415 self.lib_session = Session self.session: Session = None self.channel: Channel = None self.lib_auth_exception = AuthenticationError self.lib_base_exception = SSH2Error except ModuleNotFoundError as exc: err = f"Module '{exc.name}' not installed!" msg = f"***** {err} {'*' * (80 - len(err))}" fix = ( f"To resolve this issue, install '{exc.name}'. You can do this in one of the " "following ways:\n" "1: 'pip install -r requirements-ssh2.txt'\n" "2: 'pip install scrapli[ssh2]'") warning = "\n" + msg + "\n" + fix + "\n" + msg warnings.warn(warning) LOG.warning(warning) raise MissingDependencies self.socket = Socket(host=self.host, port=self.port, timeout=self.timeout_socket) @staticmethod def _process_ssh_config( host: str, ssh_config_file: str) -> Tuple[Optional[int], str, str]: """ Method to parse ssh config file In the future this may move to be a "helper" function as it should be very similar between paramiko and and ssh2-python... for now it can be a static method as there may be varying supported args between the two transport drivers. Args: host: host to lookup in ssh config file ssh_config_file: string path to ssh config file; passed down from `Scrape`, or the `NetworkDriver` or subclasses of it, in most cases. Returns: Tuple: port to use for ssh, username to use for ssh, identity file (private key) to use for ssh auth Raises: N/A """ ssh = SSHConfig(ssh_config_file) host_config = ssh.lookup(host) return host_config.port, host_config.user or "", host_config.identity_file or "" def open(self) -> None: """ Parent method to open session, authenticate and acquire shell Args: N/A Returns: N/A # noqa: DAR202 Raises: exc: if socket handshake fails ScrapliAuthenticationFailed: if all authentication means fail """ if not self.socket.socket_isalive(): self.socket.socket_open() self.session_lock.acquire() self.session = self.lib_session() self.set_timeout(self.timeout_transport) try: self.session.handshake(self.socket.sock) except Exception as exc: LOG.critical( f"Failed to complete handshake with host {self.host}; " f"Exception: {exc}") self.session_lock.release() raise exc if self.auth_strict_key: LOG.debug(f"Attempting to validate {self.host} public key") self._verify_key() LOG.debug(f"Session to host {self.host} opened") self.authenticate() if not self.isauthenticated(): msg = f"Authentication to host {self.host} failed" LOG.critical(msg) self.session_lock.release() raise ScrapliAuthenticationFailed(msg) self._open_channel() if self.keepalive: self._session_keepalive() self.session_lock.release() def _verify_key(self) -> None: """ Verify target host public key, raise exception if invalid/unknown Args: N/A Returns: N/A # noqa: DAR202 Raises: KeyVerificationFailed: if public key verification fails """ known_hosts = SSHKnownHosts(self.ssh_known_hosts_file) if self.host not in known_hosts.hosts.keys(): raise KeyVerificationFailed(f"{self.host} not in known_hosts!") remote_server_key_info = self.session.hostkey() encoded_remote_server_key = remote_server_key_info[0] raw_remote_public_key = base64.encodebytes(encoded_remote_server_key) remote_public_key = raw_remote_public_key.replace(b"\n", b"").decode() if known_hosts.hosts[self.host]["public_key"] != remote_public_key: raise KeyVerificationFailed( f"{self.host} in known_hosts but public key does not match!") def authenticate(self) -> None: """ Parent method to try all means of authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: ScrapliAuthenticationFailed: if auth fails """ if self.auth_private_key: self._authenticate_public_key() if self.isauthenticated(): LOG.debug( f"Authenticated to host {self.host} with public key auth") return if not self.auth_password or not self.auth_username: msg = ( f"Public key authentication to host {self.host} failed. Missing username or" " password unable to attempt password authentication.") LOG.critical(msg) raise ScrapliAuthenticationFailed(msg) self._authenticate_password() if self.isauthenticated(): LOG.debug(f"Authenticated to host {self.host} with password") return self._authenticate_keyboard_interactive() if self.isauthenticated(): LOG.debug( f"Authenticated to host {self.host} with keyboard interactive") def _authenticate_public_key(self) -> None: """ Attempt to authenticate with public key authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: exc: (really the ssh2 auth exception) if auth exception occurs exc: (really the ssh2 base exception) if unknown exception occurs """ try: self.session.userauth_publickey_fromfile(self.auth_username, self.auth_private_key) except self.lib_auth_exception as exc: LOG.critical( f"Public key authentication with host {self.host} failed. Exception: {exc}." ) except self.lib_base_exception as exc: LOG.critical( "Unknown error occurred during public key authentication with host " f"{self.host}; Exception: {exc}") def _authenticate_password(self) -> None: """ Attempt to authenticate with password authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: exc: if unknown (i.e. not auth failed) exception occurs """ try: self.session.userauth_password(self.auth_username, self.auth_password) except self.lib_auth_exception as exc: LOG.critical( f"Password authentication with host {self.host} failed. Exception: {exc}." ) except Exception as exc: LOG.critical( "Unknown error occurred during password authentication with host " f"{self.host}; Exception: {exc}") raise exc def _authenticate_keyboard_interactive(self) -> None: """ Attempt to authenticate with keyboard interactive authentication Args: N/A Returns: N/A # noqa: DAR202 Raises: exc: if unknown (i.e. not auth failed) exception occurs """ try: self.session.userauth_keyboardinteractive( # pylint: disable=C0415 self.auth_username, self.auth_password) except AttributeError as exc: LOG.critical( "Keyboard interactive authentication not supported in your ssh2-python version. " f"Exception: {exc}") except self.lib_auth_exception as exc: LOG.critical( f"Keyboard interactive authentication with host {self.host} failed. " f"Exception: {exc}.") except Exception as exc: LOG.critical( "Unknown error occurred during keyboard interactive authentication with host " f"{self.host}; Exception: {exc}") raise exc def isauthenticated(self) -> bool: """ Check if session is authenticated Args: N/A Returns: bool: True if authenticated, else False Raises: N/A """ authenticated: bool = self.session.userauth_authenticated() return authenticated def _open_channel(self) -> None: """ Open channel, acquire pty, request interactive shell Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.channel = self.session.open_session() self.channel.pty() self.channel.shell() LOG.debug(f"Channel to host {self.host} opened") def close(self) -> None: """ Close session and socket Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.session_lock.acquire() self.channel.close() LOG.debug(f"Channel to host {self.host} closed") self.socket.socket_close() self.session_lock.release() def isalive(self) -> bool: """ Check if socket is alive and session is authenticated Args: N/A Returns: bool: True if socket is alive and session authenticated, else False Raises: N/A """ if self.socket.socket_isalive( ) and not self.channel.eof() and self.isauthenticated(): return True return False def read(self) -> bytes: """ Read data from the channel Args: N/A Returns: bytes: bytes output as read from channel Raises: N/A """ output: bytes _, output = self.channel.read(65535) return output def write(self, channel_input: str) -> None: """ Write data to the channel Args: channel_input: string to send to channel Returns: N/A # noqa: DAR202 Raises: N/A """ self.channel.write(channel_input) def set_timeout(self, timeout: Optional[int] = None) -> None: """ Set session timeout Args: timeout: timeout in seconds Returns: N/A # noqa: DAR202 Raises: N/A """ if isinstance(timeout, int): set_timeout = timeout else: set_timeout = self.timeout_transport # ssh2-python expects timeout in milliseconds self.session.set_timeout(set_timeout * 1000) def _keepalive_network(self) -> None: """ ssh2-specific keepalive network Without manually closing sessions ssh2-python seems to block and keep scripts from exiting. This is a hacky fix for that by force killing the transport connection atexit. Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ def kill_transport() -> None: if self.isalive(): self.close() atexit.register(kill_transport) super()._keepalive_network() def _keepalive_standard(self) -> None: """ Send "out of band" (protocol level) keepalives to devices. Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.session.keepalive_config(want_reply=False, interval=self.keepalive_interval) while True: if not self.isalive(): return LOG.debug("Sending 'standard' keepalive.") self.session.keepalive_send() time.sleep(self.keepalive_interval / 10)
def test__repr(): sock = Socket("localhost", 22, 1) assert ( repr(sock) == "Socket {'logger': 'scrapli.socket-localhost', 'host': 'localhost', 'port': 22, 'timeout': " "1, 'sock': None}")
def test_socket_connection_not_opened(): sock = Socket("x", 2222, 1) with pytest.raises(ConnectionNotOpened) as e: sock.socket_open() assert str(e.value) == "Failed to determine socket address family for host"