p1 = Popen(["grep", "-v", "not"], stdin=PIPE, stdout=PIPE) # bufsize does not change the outcome #p2 = Popen(["cut", "-c", "1-10"], stdin=p1.stdout, stdout=PIPE, close_fds=True) p1.stdin.write('Hello World\n' * 20000) p1.stdin.close() p1.poll() #result = p2.stdout.read() print "Got the output, buffer not exceeded." elif test == 1: import pexpect p1 = pexpect.spawn('grep -v not', maxread=1) p1.send('Hello World\n' * 10000) print "Is alive:", p1.isalive() elif test == 2: from subprocess import Popen, PIPE p = Popen(["python", '-c', 'import sys; sys.stdout.write(\'Hello World\\n\'* 200000)'], stdin=PIPE, stdout=PIPE) print p.poll() o,e = p.communicate() print "Got the output, buffer not exceeded." else: import sys sys.stdout.write('Hello World\n' * 20000)
class SystemSSHTransport(Transport): def __init__( self, host: str = "", port: int = 22, auth_username: str = "", auth_private_key: str = "", auth_password: str = "", auth_strict_key: bool = True, auth_bypass: bool = False, timeout_socket: int = 5, timeout_transport: int = 5, timeout_ops: int = 10, timeout_exit: bool = True, keepalive: bool = False, keepalive_interval: int = 30, keepalive_type: str = "", keepalive_pattern: str = "\005", comms_prompt_pattern: str = r"^[a-z0-9.\-@()/:]{1,32}[#>$]$", comms_return_char: str = "\n", comms_ansi: bool = False, ssh_config_file: str = "", ssh_known_hosts_file: str = "", transport_options: Optional[Dict[str, Any]] = None, ) -> None: r""" SystemSSHTransport Object Inherit from Transport ABC SSH2Transport <- Transport (ABC) If using this driver, and passing a ssh_config_file (or setting this argument to `True`), all settings in the ssh config file will be superseded by any arguments passed here! SystemSSHTransport *always* prefers public key auth if given the option! If auth_private_key is set in the provided arguments OR if ssh_config_file is passed/True and there is a key for ANY match (i.e. `*` has a key in ssh config file!!), we will use that key! If public key auth fails and a username and password is set (manually or by ssh config file), password auth will be attempted. Note that comms_prompt_pattern, comms_return_char and comms_ansi are only passed here to handle "in channel" authentication required by SystemSSH -- these are assigned to private attributes in this class and ignored after authentication. If you wish to modify these values on a "live" scrapli connection, modify them in the Channel object, i.e. `conn.channel.comms_prompt_pattern`. Additionally timeout_ops is passed and assigned to _timeout_ops to use the same timeout_ops that is used in Channel to decorate the authentication methods here. 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) auth_bypass: bypass ssh key or password auth for devices without authentication, or that have auth prompts after ssh session establishment timeout_socket: timeout for ssh session to start -- this directly maps to ConnectTimeout ssh argument; see `man ssh_config` timeout_transport: timeout for transport in seconds. since system ssh is using popen/pty we can't really set a timeout directly, so this value governs the time timeout decorator for the transport read and write methods timeout_ops: timeout for telnet channel operations in seconds -- this is also the timeout for finding and responding to username and password prompts at initial login. This is assigned to a private attribute and is ignored after authentication is completed. 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 for system ssh 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' comms_prompt_pattern: prompt pattern expected for device, same as the one provided to channel -- system ssh needs to know this to know how to decide if we are properly sending/receiving data -- i.e. we are not stuck at some password prompt or some other failure scenario. If using driver, this should be passed from driver (Scrape, or IOSXE, etc.) to this Transport class. This is assigned to a private attribute and is ignored after authentication is completed. comms_return_char: return character to use on the channel, same as the one provided to channel -- system ssh needs to know this to know what to send so that we can probe the channel to make sure we are authenticated and sending/receiving data. If using driver, this should be passed from driver (Scrape, or IOSXE, etc.) to this Transport class. This is assigned to a private attribute and is ignored after authentication is completed. comms_ansi: True/False strip comms_ansi characters from output; this value is assigned self._comms_ansi and is ignored after authentication. We only need it for transport on the off chance (maybe never?) that username/password prompts contain ansi characters, otherwise "comms_ansi" is really a channel attribute and is treated as such. This is assigned to a private attribute and is ignored after authentication is completed. ssh_config_file: string to path for ssh config file ssh_known_hosts_file: string to path for ssh known hosts file transport_options: SystemSSHTransport specific transport options (options that don't apply to any of the other transport classes) supplied in a dictionary where the key is the name of the option and the value is of course the value. - open_cmd: string or list of strings to extend the open_cmd with, for example: `["-o", "KexAlgorithms=+diffie-hellman-group1-sha1"]` or: `-oKexAlgorithms=+diffie-hellman-group1-sha1` these commands will be appended to the open command that scrapli builds which looks something like the following depending on the inputs provided: ssh 172.31.254.1 -p 22 -o ConnectTimeout=5 -o ServerAliveInterval=10 -l scrapli -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -F /dev/null You can pass any arguments that would be supported if you were ssh'ing on your terminal "normally", passing some bad arguments can break things! Returns: N/A # noqa: DAR202 Raises: N/A """ super().__init__( host, port, timeout_socket, timeout_transport, timeout_exit, keepalive, keepalive_interval, keepalive_type, keepalive_pattern, ) self.auth_username: str = auth_username self.auth_private_key: str = auth_private_key self.auth_password: str = auth_password self.auth_strict_key: bool = auth_strict_key self.auth_bypass: bool = auth_bypass self._timeout_ops: int = timeout_ops self._comms_prompt_pattern: str = comms_prompt_pattern self._comms_return_char: str = comms_return_char self._comms_ansi: bool = comms_ansi self._process_ssh_config(ssh_config_file) self.ssh_known_hosts_file: str = ssh_known_hosts_file self.session: Union[Popen[bytes], PtyProcess] # pylint: disable=E1136 self.lib_auth_exception = ScrapliAuthenticationFailed self._isauthenticated = False # ensure we set transport_options to a dict if its left as None self.transport_options = transport_options or {} self.open_cmd = ["ssh", self.host] self._build_open_cmd() # create stdin/stdout fd in case we can use pipes for session self._stdin_fd = -1 self._stdout_fd = -1 def _process_ssh_config(self, ssh_config_file: str) -> None: """ Method to parse ssh config file Ensure ssh_config_file is valid (if providing a string path to config file), or resolve config file if passed True. Search config file for any private key, if ANY matching key is found and user has not provided a private key, set `auth_private_key` to the value of the found key. This is because we prefer to use `open_pipes` over `open_pty`! Args: ssh_config_file: string path to ssh config file; passed down from `Scrape`, or the `NetworkDriver` or subclasses of it, in most cases. Returns: N/A # noqa: DAR202 Raises: N/A """ ssh = SSHConfig(ssh_config_file=ssh_config_file) self.ssh_config_file = ssh.ssh_config_file host_config = ssh.lookup(host=self.host) if not self.auth_private_key and host_config.identity_file: self.auth_private_key = os.path.expanduser( host_config.identity_file.strip()) def _build_open_cmd(self) -> None: """ Method to craft command to open ssh session Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.open_cmd.extend(["-p", str(self.port)]) self.open_cmd.extend(["-o", f"ConnectTimeout={self.timeout_socket}"]) self.open_cmd.extend( ["-o", f"ServerAliveInterval={self.timeout_transport}"]) if self.auth_private_key: self.open_cmd.extend(["-i", self.auth_private_key]) if self.auth_username: self.open_cmd.extend(["-l", self.auth_username]) if self.auth_strict_key is False: self.open_cmd.extend(["-o", "StrictHostKeyChecking=no"]) self.open_cmd.extend(["-o", "UserKnownHostsFile=/dev/null"]) else: self.open_cmd.extend(["-o", "StrictHostKeyChecking=yes"]) if self.ssh_known_hosts_file: self.open_cmd.extend( ["-o", f"UserKnownHostsFile={self.ssh_known_hosts_file}"]) if self.ssh_config_file: self.open_cmd.extend(["-F", self.ssh_config_file]) else: self.open_cmd.extend(["-F", "/dev/null"]) user_args = self.transport_options.get("open_cmd", []) if isinstance(user_args, str): user_args = [user_args] self.open_cmd.extend(user_args) def open(self) -> None: """ Parent method to open session, authenticate and acquire shell If possible it is preferable to use the `_open_pipes` method, but we can only do this IF we can authenticate with public key authorization (because we don't have to spawn a PTY; if no public key we have to spawn PTY to deal w/ authentication prompts). IF we get a private key provided, use pipes method, otherwise we will just deal with `_open_pty`. `_open_pty` is less preferable because we have to spawn a PTY and cannot as easily tell if SSH authentication is successful. With `_open_pipes` we can read stderr which contains the output from the verbose flag for SSH -- this contains a message that indicates success of SSH auth. In the case of `_open_pty` we have to read from the channel directly like in the case of telnet... so it works, but its just a bit less desirable. Args: N/A Returns: N/A # noqa: DAR202 Raises: ScrapliAuthenticationFailed: if all authentication means fail """ self.session_lock.acquire() self.logger.info(f"Attempting to authenticate to {self.host}") # if auth_bypass kick off keepalive thread if necessary and return if self.auth_bypass: self.logger.info("`auth_bypass` is True, bypassing authentication") self._open_pty(skip_auth=True) self._session_keepalive() return # if authenticating with private key prefer to use open pipes # _open_pipes uses subprocess Popen which is preferable to opening a pty # if _open_pipes fails and no password available, raise failure, otherwise try password auth if self.auth_private_key: open_pipes_result = self._open_pipes() if open_pipes_result: if self.keepalive: self._session_keepalive() 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.") self.logger.critical(msg) raise ScrapliAuthenticationFailed(msg) msg = ( f"Failed to authenticate to host {self.host} with private key " f"`{self.auth_private_key}`. Attempting to continue with password authentication." ) self.logger.critical(msg) # If public key auth fails or is not configured, open a pty session if not self._open_pty(): msg = f"Authentication to host {self.host} failed" self.logger.critical(msg) raise ScrapliAuthenticationFailed(msg) self.logger.info(f"Successfully authenticated to {self.host}") if self.keepalive: self._session_keepalive() def _open_pipes(self) -> bool: """ Private method to open session with subprocess.Popen Args: N/A Returns: bool: True/False session was opened and authenticated Raises: N/A """ # import here so that we dont blow up when running on windows (windows users need to use # ssh2 or paramiko transport) import pty # pylint: disable=C0415 # copy the open_cmd as we don't want to update the objects open_cmd until we know we can # authenticate. add verbose output and disable batch mode (disables passphrase/password # queries). If auth is successful update the object open_cmd to represent what was used open_cmd = self.open_cmd.copy() open_cmd.append("-v") open_cmd.extend(["-o", "BatchMode=yes"]) self.logger.info( f"Attempting to open session with the following command: {open_cmd}" ) stdout_master_pty, stdout_slave_pty = pty.openpty() stdin_master_pty, stdin_slave_pty = pty.openpty() self.session = Popen( open_cmd, bufsize=0, shell=False, stdin=stdin_slave_pty, stdout=stdout_slave_pty, stderr=PIPE, ) # close the slave fds, don't need them anymore os.close(stdin_slave_pty) os.close(stdout_slave_pty) self.logger.debug(f"Session to host {self.host} spawned") try: self._pipes_isauthenticated(self.session) if self._isauthenticated is False: return False except TimeoutError: # If auth fails, kill the popen session, also need to manually close the stderr pipe # for some reason... unclear why, but w/out this it will hang open if self.session.stderr is not None: stderr_fd = self.session.stderr.fileno() os.close(stderr_fd) self.session.kill() # close the ptys we forked os.close(stdin_master_pty) os.close(stdout_master_pty) # it seems that killing the process/fds somehow unlocks the thread? very unsure how/why self.session_lock.acquire() return False self.logger.debug(f"Authenticated to host {self.host} with public key") # set stdin/stdout to the new master pty fds self._stdin_fd = stdin_master_pty self._stdout_fd = stdout_master_pty self.open_cmd = open_cmd self.session_lock.release() return True def _ssh_message_handler(self, output: bytes) -> None: # noqa: C901 """ Parse EOF messages from _pty_authenticate and create log/stack exception message Args: output: bytes output from _pty_authenticate Returns: N/A # noqa: DAR202 Raises: ScrapliAuthenticationFailed: if any errors are read in the output """ msg = "" if b"host key verification failed" in output.lower(): msg = f"Host key verification failed for host {self.host}" elif b"operation timed out" in output.lower( ) or b"connection timed out" in output.lower(): msg = f"Timed out connecting to host {self.host}" elif b"no route to host" in output.lower(): msg = f"No route to host {self.host}" elif b"no matching key exchange found" in output.lower(): msg = f"No matching key exchange found for host {self.host}" key_exchange_pattern = re.compile( pattern=rb"their offer: ([a-z0-9\-,]*)", flags=re.M | re.I) offered_key_exchanges_match = re.search( pattern=key_exchange_pattern, string=output) if offered_key_exchanges_match: offered_key_exchanges = offered_key_exchanges_match.group( 1).decode() msg = ( f"No matching key exchange found for host {self.host}, their offer: " f"{offered_key_exchanges}") elif b"no matching cipher found" in output.lower(): msg = f"No matching cipher found for host {self.host}" ciphers_pattern = re.compile( pattern=rb"their offer: ([a-z0-9\-,]*)", flags=re.M | re.I) offered_ciphers_match = re.search(pattern=ciphers_pattern, string=output) if offered_ciphers_match: offered_ciphers = offered_ciphers_match.group(1).decode() msg = ( f"No matching cipher found for host {self.host}, their offer: {offered_ciphers}" ) elif b"WARNING: UNPROTECTED PRIVATE KEY FILE!" in output: msg = ( f"Permissions for private key `{self.auth_private_key}` are too open, " "authentication failed!") elif b"could not resolve hostname" in output.lower(): msg = f"Could not resolve address for host `{self.host}`" if msg: self.logger.critical(msg) raise ScrapliAuthenticationFailed(msg) @operation_timeout("_timeout_ops", "Timed out determining if session is authenticated") def _pipes_isauthenticated(self, pipes_session: "PopenBytes") -> bool: """ Private method to check initial authentication when using subprocess.Popen Since we always run ssh with `-v` we can simply check the stderr (where verbose output goes) to see if `Authenticated to [our host]` is in the output. Args: pipes_session: Popen pipes session object Returns: bool: True/False session was authenticated Raises: ScrapliTimeout: if we cant read from stderr of the session """ if pipes_session.stderr is None: raise ScrapliTimeout( f"Could not read stderr while connecting to host {self.host}") output = b"" while True: output += pipes_session.stderr.read(65535) if f"authenticated to {self.host}".encode() in output.lower(): self._isauthenticated = True return True if (b"next authentication method: keyboard-interactive" in output.lower() or b"next authentication method: password" in output.lower()): return False self._ssh_message_handler(output=output) def _open_pty(self, skip_auth: bool = False) -> bool: """ Private method to open session with PtyProcess Args: skip_auth: skip auth in the case of auth_bypass mode Returns: bool: True/False session was opened and authenticated Raises: N/A """ self.logger.info( f"Attempting to open session with the following command: {self.open_cmd}" ) self.session = PtyProcess.spawn(self.open_cmd) self.logger.debug(f"Session to host {self.host} spawned") self.session_lock.release() if skip_auth: return True self._pty_authenticate(pty_session=self.session) if not self._pty_isauthenticated(self.session): return False self.logger.debug(f"Authenticated to host {self.host} with password") return True @operation_timeout("_timeout_ops", "Timed out looking for SSH login password prompt") def _pty_authenticate(self, pty_session: PtyProcess) -> None: """ Private method to check initial authentication when using pty_session Args: pty_session: PtyProcess session object Returns: N/A # noqa: DAR202 Raises: ScrapliAuthenticationFailed: if we get EOF and _ssh_message_handler does not raise an explicit exception/message """ self.session_lock.acquire() output = b"" while True: try: new_output = pty_session.read() output += new_output self.logger.debug( f"Attempting to authenticate. Read: {repr(new_output)}") except EOFError: self._ssh_message_handler(output=output) # if _ssh_message_handler didn't raise any exception, we can raise the standard -- # did you disable strict key message/exception msg = ( f"Failed to open connection to host {self.host}. Do you need to disable " "`auth_strict_key`?") self.logger.critical(msg) raise ScrapliAuthenticationFailed(msg) if self._comms_ansi: output = strip_ansi(output) if b"password" in output.lower(): self.logger.info("Found password prompt, sending password") pty_session.write(self.auth_password.encode()) pty_session.write(self._comms_return_char.encode()) self.session_lock.release() break @operation_timeout("_timeout_ops", "Timed out determining if session is authenticated") def _pty_isauthenticated(self, pty_session: PtyProcess) -> bool: """ Check if session is authenticated This is very naive -- it only knows if the sub process is alive and has not received an EOF. Beyond that we lock the session and send the return character and re-read the channel. Args: pty_session: PtyProcess session object Returns: bool: True if authenticated, else False Raises: N/A """ self.logger.debug( "Attempting to determine if PTY authentication was successful") if pty_session.isalive() and not pty_session.eof(): prompt_pattern = get_prompt_pattern( prompt="", class_prompt=self._comms_prompt_pattern) self.session_lock.acquire() pty_session.write(self._comms_return_char.encode()) while True: # almost all of the time we don't need a while loop here, but every once in a while # fd won't be ready which causes a failure without an obvious root cause, # loop/logging to hopefully help with that fd_ready, _, _ = select([pty_session.fd], [], [], 0) if pty_session.fd in fd_ready: break self.logger.debug("PTY fd not ready yet...") output = b"" while True: new_output = pty_session.read() output += new_output self.logger.debug( f"Attempting to validate authentication. Read: {repr(new_output)}" ) # we do not need to deal w/ line replacement for the actual output, only for # parsing if a prompt-like thing is at the end of the output output = output.replace(b"\r", b"") # always check to see if we should strip ansi here; if we don't handle this we # may raise auth failures for the wrong reason which would be confusing for # users if b"\x1B" in output: output = strip_ansi(output=output) channel_match = re.search(pattern=prompt_pattern, string=output) if channel_match: self.session_lock.release() self._isauthenticated = True return True if b"password:"******"password" we know auth failed (hopefully in all scenarios!) self.logger.critical( "Found `password:` in output, assuming password authentication failed" ) return False if output: self.logger.debug( f"Cannot determine if authenticated, \n\tRead: {repr(output)}" ) self.session_lock.release() return False def close(self) -> None: """ Close session and socket Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.session_lock.acquire() if isinstance(self.session, Popen): self.session.kill() try: os.close(self._stdout_fd) os.close(self._stdin_fd) except OSError: # fds were never opened or were already closed pass elif isinstance(self.session, PtyProcess): # killing ptyprocess seems to make things hang open? self.session.terminated = True self.logger.debug(f"Channel to host {self.host} closed") self.session_lock.release() def isalive(self) -> bool: """ Check if session is alive and session is authenticated Args: N/A Returns: bool: True if session is alive and session authenticated, else False Raises: N/A """ if isinstance(self.session, Popen): try: os.stat(self._stdout_fd) os.stat(self._stdin_fd) return True except OSError: return False elif isinstance(self.session, PtyProcess): if self.session.isalive( ) and self._isauthenticated and not self.session.eof(): return True return False @requires_open_session() @operation_timeout("timeout_transport", "Timed out reading from transport") def read(self) -> bytes: """ Read data from the channel Args: N/A Returns: bytes: bytes output as read from channel Raises: N/A """ read_bytes = 65535 if isinstance(self.session, Popen): return os.read(self._stdout_fd, read_bytes) if isinstance(self.session, PtyProcess): return self.session.read(read_bytes) return b"" @requires_open_session() @operation_timeout("timeout_transport", "Timed out writing to transport") 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 """ if isinstance(self.session, Popen): os.write(self._stdin_fd, channel_input.encode()) elif isinstance(self.session, PtyProcess): self.session.write(channel_input.encode()) def set_timeout(self, timeout: Optional[int] = None) -> None: """ Set session timeout Note that this modifies the objects `timeout_transport` value directly as this value is what controls the timeout decorator for read/write methods. This is slightly different behavior from ssh2/paramiko/telnet in that those transports modify the session value and leave the objects `timeout_transport` alone. 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.timeout_transport = 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: 'standard' keepalive mechanism not yet implemented for system """ raise NotImplementedError( "'standard' keepalive mechanism not yet implemented for system.")
class SystemSSHTransport(Transport): def __init__( self, host: str = "", port: int = 22, auth_username: str = "", auth_private_key: str = "", auth_password: str = "", auth_strict_key: bool = True, timeout_socket: int = 5, timeout_transport: int = 5, timeout_ops: int = 10, timeout_exit: bool = True, keepalive: bool = False, keepalive_interval: int = 30, keepalive_type: str = "", keepalive_pattern: str = "\005", comms_prompt_pattern: str = r"^[a-z0-9.\-@()/:]{1,32}[#>$]$", comms_return_char: str = "\n", comms_ansi: bool = False, ssh_config_file: str = "", ssh_known_hosts_file: str = "", ) -> None: """ SystemSSHTransport Object Inherit from Transport ABC SSH2Transport <- Transport (ABC) If using this driver, and passing a ssh_config_file (or setting this argument to `True`), all settings in the ssh config file will be superseded by any arguments passed here! SystemSSHTransport *always* prefers public key auth if given the option! If auth_private_key is set in the provided arguments OR if ssh_config_file is passed/True and there is a key for ANY match (i.e. `*` has a key in ssh config file!!), we will use that key! If public key auth fails and a username and password is set (manually or by ssh config file), password auth will be attempted. Note that comms_prompt_pattern, comms_return_char and comms_ansi are only passed here to handle "in channel" authentication required by SystemSSH -- these are assigned to private attributes in this class and ignored after authentication. If you wish to modify these values on a "live" scrapli connection, modify them in the Channel object, i.e. `conn.channel.comms_prompt_pattern`. Additionally timeout_ops is passed and assigned to _timeout_ops to use the same timeout_ops that is used in Channel to decorate the authentication methods here. 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 ssh session to start -- this directly maps to ConnectTimeout ssh argument; see `man ssh_config` timeout_transport: timeout for transport in seconds. since system ssh is using popen/pty we can't really set a timeout directly, so this value governs the time timeout decorator for the transport read and write methods timeout_ops: timeout for telnet channel operations in seconds -- this is also the timeout for finding and responding to username and password prompts at initial login. This is assigned to a private attribute and is ignored after authentication is completed. 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 for system ssh 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' comms_prompt_pattern: prompt pattern expected for device, same as the one provided to channel -- system ssh needs to know this to know how to decide if we are properly sending/receiving data -- i.e. we are not stuck at some password prompt or some other failure scenario. If using driver, this should be passed from driver (Scrape, or IOSXE, etc.) to this Transport class. This is assigned to a private attribute and is ignored after authentication is completed. comms_return_char: return character to use on the channel, same as the one provided to channel -- system ssh needs to know this to know what to send so that we can probe the channel to make sure we are authenticated and sending/receiving data. If using driver, this should be passed from driver (Scrape, or IOSXE, etc.) to this Transport class. This is assigned to a private attribute and is ignored after authentication is completed. comms_ansi: True/False strip comms_ansi characters from output; this value is assigned self._comms_ansi and is ignored after authentication. We only need it for transport on the off chance (maybe never?) that username/password prompts contain ansi characters, otherwise "comms_ansi" is really a channel attribute and is treated as such. This is assigned to a private attribute and is ignored after authentication is completed. 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 """ super().__init__( host, port, timeout_socket, timeout_transport, timeout_exit, keepalive, keepalive_interval, keepalive_type, keepalive_pattern, ) self.auth_username: str = auth_username self.auth_private_key: str = auth_private_key self.auth_password: str = auth_password self.auth_strict_key: bool = auth_strict_key self._timeout_ops: int = timeout_ops self._comms_prompt_pattern: str = comms_prompt_pattern self._comms_return_char: str = comms_return_char self._comms_ansi: bool = comms_ansi self._process_ssh_config(ssh_config_file) self.ssh_known_hosts_file: str = ssh_known_hosts_file self.session: Union[Popen[bytes], PtyProcess] # pylint: disable=E1136 self.lib_auth_exception = ScrapliAuthenticationFailed self._isauthenticated = False self.open_cmd = ["ssh", self.host] self._build_open_cmd() # create stdin/stdout fd in case we can use pipes for session self._stdin_fd = -1 self._stdout_fd = -1 def _process_ssh_config(self, ssh_config_file: str) -> None: """ Method to parse ssh config file Ensure ssh_config_file is valid (if providing a string path to config file), or resolve config file if passed True. Search config file for any private key, if ANY matching key is found and user has not provided a private key, set `auth_private_key` to the value of the found key. This is because we prefer to use `open_pipes` over `open_pty`! Args: ssh_config_file: string path to ssh config file; passed down from `Scrape`, or the `NetworkDriver` or subclasses of it, in most cases. Returns: N/A # noqa: DAR202 Raises: N/A """ ssh = SSHConfig(ssh_config_file) self.ssh_config_file = ssh.ssh_config_file host_config = ssh.lookup(self.host) if not self.auth_private_key and host_config.identity_file: self.auth_private_key = os.path.expanduser( host_config.identity_file.strip()) def _build_open_cmd(self) -> None: """ Method to craft command to open ssh session Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.open_cmd.extend(["-p", str(self.port)]) self.open_cmd.extend(["-o", f"ConnectTimeout={self.timeout_socket}"]) if self.auth_private_key: self.open_cmd.extend(["-i", self.auth_private_key]) if self.auth_username: self.open_cmd.extend(["-l", self.auth_username]) if self.auth_strict_key is False: self.open_cmd.extend(["-o", "StrictHostKeyChecking=no"]) self.open_cmd.extend(["-o", "UserKnownHostsFile=/dev/null"]) else: self.open_cmd.extend(["-o", "StrictHostKeyChecking=yes"]) if self.ssh_known_hosts_file: self.open_cmd.extend( ["-o", f"UserKnownHostsFile={self.ssh_known_hosts_file}"]) if self.ssh_config_file: self.open_cmd.extend(["-F", self.ssh_config_file]) else: self.open_cmd.extend(["-F", "/dev/null"]) def open(self) -> None: """ Parent method to open session, authenticate and acquire shell If possible it is preferable to use the `_open_pipes` method, but we can only do this IF we can authenticate with public key authorization (because we don't have to spawn a PTY; if no public key we have to spawn PTY to deal w/ authentication prompts). IF we get a private key provided, use pipes method, otherwise we will just deal with `_open_pty`. `_open_pty` is less preferable because we have to spawn a PTY and cannot as easily tell if SSH authentication is successful. With `_open_pipes` we can read stderr which contains the output from the verbose flag for SSH -- this contains a message that indicates success of SSH auth. In the case of `_open_pty` we have to read from the channel directly like in the case of telnet... so it works, but its just a bit less desirable. Args: N/A Returns: N/A # noqa: DAR202 Raises: ScrapliAuthenticationFailed: if all authentication means fail """ self.session_lock.acquire() # If authenticating with private key prefer to use open pipes # _open_pipes uses subprocess Popen which is preferable to opening a pty # if _open_pipes fails and no password available, raise failure, otherwise try password auth if self.auth_private_key: open_pipes_result = self._open_pipes() if open_pipes_result: 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) # If public key auth fails or is not configured, open a pty session if not self._open_pty(): msg = f"Authentication to host {self.host} failed" LOG.critical(msg) raise ScrapliAuthenticationFailed(msg) if self.keepalive: self._session_keepalive() def _open_pipes(self) -> bool: """ Private method to open session with subprocess.Popen Args: N/A Returns: bool: True/False session was opened and authenticated Raises: N/A """ # copy the open_cmd as we don't want to update the objects open_cmd until we know we can # authenticate. add verbose output and disable batch mode (disables passphrase/password # queries). If auth is successful update the object open_cmd to represent what was used open_cmd = self.open_cmd.copy() open_cmd.append("-v") open_cmd.extend(["-o", "BatchMode=yes"]) stdout_master_pty, stdout_slave_pty = pty.openpty() stdin_master_pty, stdin_slave_pty = pty.openpty() self.session = Popen( open_cmd, bufsize=0, shell=False, stdin=stdin_slave_pty, stdout=stdout_slave_pty, stderr=PIPE, ) # close the slave fds, don't need them anymore os.close(stdin_slave_pty) os.close(stdout_slave_pty) LOG.debug(f"Session to host {self.host} spawned") try: self._pipes_isauthenticated(self.session) except TimeoutError: # If auth fails, kill the popen session, also need to manually close the stderr pipe # for some reason... unclear why, but w/out this it will hang open if self.session.stderr is not None: stderr_fd = self.session.stderr.fileno() os.close(stderr_fd) self.session.kill() return False LOG.debug(f"Authenticated to host {self.host} with public key") # set stdin/stdout to the new master pty fds self._stdin_fd = stdin_master_pty self._stdout_fd = stdout_master_pty self.open_cmd = open_cmd self.session_lock.release() return True @operation_timeout("_timeout_ops", "Timed out determining if session is authenticated") def _pipes_isauthenticated(self, pipes_session: "PopenBytes") -> bool: """ Private method to check initial authentication when using subprocess.Popen Since we always run ssh with `-v` we can simply check the stderr (where verbose output goes) to see if `Authenticated to [our host]` is in the output. Args: pipes_session: Popen pipes session object Returns: bool: True/False session was authenticated Raises: ScrapliTimeout: if `Operation timed out` in stderr output """ if pipes_session.stderr is None: raise ScrapliTimeout( f"Could not read stderr while connecting to host {self.host}") output = b"" while True: output += pipes_session.stderr.read(65535) if f"Authenticated to {self.host}".encode() in output: self._isauthenticated = True return True if "Operation timed out".encode() in output: raise ScrapliTimeout( f"Timed opening connection to host {self.host}") def _open_pty(self) -> bool: """ Private method to open session with PtyProcess Args: N/A Returns: bool: True/False session was opened and authenticated Raises: N/A """ self.session = PtyProcess.spawn(self.open_cmd) LOG.debug(f"Session to host {self.host} spawned") self.session_lock.release() self._pty_authenticate(self.session) if not self._pty_isauthenticated(self.session): return False LOG.debug(f"Authenticated to host {self.host} with password") return True @operation_timeout("_timeout_ops", "Timed out looking for SSH login password prompt") def _pty_authenticate(self, pty_session: PtyProcess) -> None: """ Private method to check initial authentication when using pty_session Args: pty_session: PtyProcess session object Returns: N/A # noqa: DAR202 Raises: ScrapliAuthenticationFailed: if we receive an EOFError -- this usually indicates that host key checking is enabled and failed. """ self.session_lock.acquire() output = b"" while True: try: output += pty_session.read() except EOFError: msg = f"Failed to open connection to host {self.host}" if b"Host key verification failed" in output: msg = f"Host key verification failed for host {self.host}" elif b"Operation timed out" in output: msg = f"Timed out connecting to host {self.host}" raise ScrapliAuthenticationFailed(msg) if self._comms_ansi: output = strip_ansi(output) if b"password" in output.lower(): LOG.debug("Found password prompt, sending password") pty_session.write(self.auth_password.encode()) pty_session.write(self._comms_return_char.encode()) self.session_lock.release() break @operation_timeout("_timeout_ops", "Timed out determining if session is authenticated") def _pty_isauthenticated(self, pty_session: PtyProcess) -> bool: """ Check if session is authenticated This is very naive -- it only knows if the sub process is alive and has not received an EOF. Beyond that we lock the session and send the return character and re-read the channel. Args: pty_session: PtyProcess session object Returns: bool: True if authenticated, else False Raises: N/A """ LOG.debug( "Attempting to determine if PTY authentication was successful") if pty_session.isalive() and not pty_session.eof(): prompt_pattern = get_prompt_pattern("", self._comms_prompt_pattern) self.session_lock.acquire() pty_session.write(self._comms_return_char.encode()) fd_ready, _, _ = select([pty_session.fd], [], [], 0) if pty_session.fd in fd_ready: output = b"" while True: output += pty_session.read() # we do not need to deal w/ line replacement for the actual output, only for # parsing if a prompt-like thing is at the end of the output output = re.sub(b"\r", b"", output) # always check to see if we should strip ansi here; if we don't handle this we # may raise auth failures for the wrong reason which would be confusing for # users if b"\x1B" in output: output = strip_ansi(output) channel_match = re.search(prompt_pattern, output) if channel_match: self.session_lock.release() self._isauthenticated = True return True if b"password" in output.lower(): # if we see "password" we know auth failed (hopefully in all scenarios!) return False if output: LOG.debug( f"Cannot determine if authenticated, \n\tRead: {repr(output)}" ) self.session_lock.release() return False def close(self) -> None: """ Close session and socket Args: N/A Returns: N/A # noqa: DAR202 Raises: N/A """ self.session_lock.acquire() if isinstance(self.session, Popen): self.session.kill() elif isinstance(self.session, PtyProcess): self.session.kill(1) LOG.debug(f"Channel to host {self.host} closed") self.session_lock.release() def isalive(self) -> bool: """ Check if session is alive and session is authenticated Args: N/A Returns: bool: True if session is alive and session authenticated, else False Raises: N/A """ if isinstance(self.session, Popen): if self.session.poll() is None and self._isauthenticated: return True elif isinstance(self.session, PtyProcess): if self.session.isalive( ) and self._isauthenticated and not self.session.eof(): return True return False @operation_timeout("timeout_transport", "Transport timeout during read operation.") def read(self) -> bytes: """ Read data from the channel Args: N/A Returns: bytes: bytes output as read from channel Raises: N/A """ read_bytes = 65535 if isinstance(self.session, Popen): return os.read(self._stdout_fd, read_bytes) if isinstance(self.session, PtyProcess): return self.session.read(read_bytes) return b"" @operation_timeout("timeout_transport", "Transport timeout during write operation.") 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 """ if isinstance(self.session, Popen): os.write(self._stdin_fd, channel_input.encode()) elif isinstance(self.session, PtyProcess): self.session.write(channel_input.encode()) def set_timeout(self, timeout: Optional[int] = None) -> None: """ Set session timeout Note that this modifies the objects `timeout_transport` value directly as this value is what controls the timeout decorator for read/write methods. This is slightly different behavior from ssh2/paramiko/telnet in that those transports modify the session value and leave the objects `timeout_transport` alone. 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.timeout_transport = 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( "'standard' keepalive mechanism not yet implemented for system.")