ManageIQ/manageiq-ssh-util

View on GitHub
lib/manageiq/ssh/util.rb

Summary

Maintainability
F
3 days
Test Coverage
F
49%
require 'net/ssh'
require 'net/sftp'
require 'tempfile'
require 'active_support'
require 'active_support/core_ext/object/blank'

module ManageIQ
  class SSH
    # Utility wrapper around the net-ssh library.
    class Util
      # The exit status of the ssh command.
      attr_reader :status

      # The name of the host provided to the constructor.
      attr_reader :host

      # The options hash passed to the constructor.
      attr_reader :options

      # The username passed to the constructor.
      attr_reader :user

      # Create and return a ManageIQ::SSH::Util object. A host, user and
      # password must be specified.
      #
      # The +options+ param may contain options that are passed directly
      # to the Net::SSH constructor. By default the :non_interactive option is
      # set to true (meaning it will fail instead of prompting for a password),
      # the :verbose level is set to :warn, and the :use_agent option is
      # set to false.
      #
      # The :logger option is not set by default. If you do set it, you should
      # NOT use an existing logger, but instead use a separate custom log.
      # If the log already exists, then the option is effectively ignored. Some
      # additional logging will be written to the global ManageIQ log in
      # debug mode.
      #
      # The following local options are also supported:
      #
      # :passwordless_sudo - If set to true, then it is assumed that the sudo
      # command does not require a password, and 'sudo' will automatically be
      # prepended to your command. For sudo that requires a password, set
      # the :su_user and :su_password options instead.
      #
      # :remember_host - Setting this to true will cause a HostKeyMismatch
      # error to be rescued and retried once after recording the host and
      # key in the known hosts file. By default this is false.
      #
      # :su_user - If set, ssh commands for that object will be executed via
      # sudo. Do not use if :passwordless_sudo is set to true.
      #
      # :su_password - When used in conjunction with :su_user, the password sent
      # to the command prompt when asked for as the result of using the su
      # command. Do not use if :passwordless_sudo is set to true.
      #
      def initialize(host, user, password = nil, options = {})
        @host     = host
        @user     = user
        @password = password

        @options  = {
          :remember_host   => false,
          :verbose         => :warn,
          :non_interactive => true,
          :use_agent       => false
        }.merge(options)

        @options[:password] = password if password

        # Pull our custom keys out of the hash because the SSH initializer will complain
        @remember_host     = @options.delete(:remember_host)
        @su_user           = @options.delete(:su_user)
        @su_password       = @options.delete(:su_password)
        @passwordless_sudo = @options.delete(:passwordless_sudo)

        # Obsolete, delete if passed in
        @options.delete(:authentication_prompt_delay)
      end

      # Returns a boolean value indicating whether or not the +remember_host+
      # option is set. This tells Net::SSH to record the host and key in the
      # known hosts file, so that subsequent connections will remember them.
      #
      def remember_host?
        !!@remember_host
      end

      # Download the contents of the remote +from+ file to the local +to+ file. Some
      # messages will be written to the global ManageIQ log in debug mode.
      #
      # Note that the returned data is normally a Net::SFTP::Operations::Download
      # object. If you want to store the file contents in memory, pass an IO object
      # as the second argument.
      #
      def get_file(from, to)
        run_session do |ssh|
          $log&.debug("#{self.class}##{__method__} - Copying file #{host}:#{from} to #{to}.")
          data = ssh.sftp.download!(from, to)
          $log&.debug("#{self.class}##{__method__} - Copying of #{host}:#{from} to #{to}, complete.")
          return data
        end
      end

      # Upload the contents of local file +to+ to remote location +path+. You may
      # use the specified +content+ instead of the content of the local file.
      #
      # At least one of the +content+ or +path+ parameters must be specified or
      # an error is raised.
      #
      def put_file(to, content = nil, path = nil)
        raise ArgumentError, "Need to provide either content or path" if content.nil? && path.nil?
        run_session do |ssh|
          content ||= IO.binread(path)
          $log&.debug("#{self.class}##{__method__} - Copying file to #{@host}:#{to}.")
          ssh.sftp.file.open(to, 'wb') { |f| f.write(content) }
          $log&.debug("#{self.class}##{__method__} - Copying of file to #{@host}:#{to}, complete.")
        end
      end

      # Execute the remote +cmd+ via ssh. This is automatically handled via
      # channels on the ssh session so that various states can be checked,
      # stored and logged independently and asynchronously.
      #
      # If the :passwordless_sudo option was set to true in the constructor
      # then the +cmd+ will automatically be prepended with "sudo".
      #
      # If specified, the data collection will stop the first time a +done_string+
      # argument is encountered at the end of a line. In practice you would
      # typically specify a newline character.
      #
      # If present, the +stdin+ argument will be sent to the underlying
      # command as input for those commands that expect it, e.g. tee.
      #
      # If a signal is received, the command returns any sort of non-zero
      # error status, or if any stderr output is encountered then an exception
      # is raised.
      #
      def exec(cmd, done_string = nil, stdin = nil)
        error_buffer = ""
        output_buffer = ""
        status = 0
        signal = nil
        header = "#{self.class}##{__method__}"

        # If passwordless sudo is true then prepend every command with 'sudo'.
        cmd = 'sudo ' + cmd if @passwordless_sudo

        run_session do |ssh|
          ssh.open_channel do |channel|
            channel.exec(cmd) do |chan, success|
              raise "#{header} - Could not execute command #{cmd}" unless success

              $log&.debug("#{header} - Command: #{cmd} started.")

              if stdin.present?
                chan.send_data(stdin)
                chan.eof!
              end

              channel.on_data do |_channel, data|
                $log&.debug("#{header} - STDOUT: #{data}")
                output_buffer << data
                data.each_line { |l| return output_buffer if done_string == l.chomp } unless done_string.nil?
              end

              channel.on_extended_data do |_channel, _type, data|
                $log&.debug("#{header} - STDERR: #{data}")
                error_buffer << data
              end

              channel.on_request('exit-status') do |_channel, data|
                status = data.read_long || 0
                $log&.debug("#{header} - STATUS: #{status}")
              end

              channel.on_request('exit-signal') do |_channel, data|
                signal = data.read_string
                $log&.debug("#{header} - SIGNAL: #{signal}")
              end

              channel.on_eof do |_channel|
                $log&.debug("#{header} - EOF RECEIVED")
              end

              channel.on_close do |_channel|
                $log&.debug("#{header} - Command: #{cmd}, exit status: #{status}")
                if signal.present? || status.nonzero? || error_buffer.present?
                  raise "#{header} - Command '#{cmd}' exited with signal #{signal}" if signal.present?
                  raise "#{header} - Command '#{cmd}' exited with status #{status}" if status.nonzero?
                  raise "#{header} - Command '#{cmd}' failed: #{error_buffer}"
                end
                return output_buffer
              end
            end # exec
          end # open_channel
          ssh.loop
        end # run_session
      end

      # Execute the remote +cmd+ via ssh. This is nearly identical to the exec
      # method, and is used only if the :su_user and :su_password options are
      # set in the constructor.
      #
      # The difference between this method and the exec method are primarily in
      # the underlying handling of the sudo user and sudo password parameters, i.e
      # creating a PTY session and dealing with prompts. From the perspective of
      # an end user they are essentially identical.
      #
      def suexec(cmd_str, done_string = nil, stdin = nil)
        error_buffer = ""
        output_buffer = ""
        prompt = ""
        cmd_rx = ""
        status = 0
        signal = nil
        state  = :initial
        header = "#{self.class}##{__method__}"

        run_session do |ssh|
          temp_cmd_file(cmd_str) do |cmd|
            ssh.open_channel do |channel|
              # now we request a "pty" (i.e. interactive) session so we can send data back and forth if needed.
              # it WILL NOT WORK without this, and it has to be done before any call to exec.
              channel.request_pty(:chars_wide => 256) do |_channel, success|
                raise "Could not obtain pty (i.e. an interactive ssh session)" unless success
              end

              channel.on_data do |channel, data|
                $log&.debug("#{header} - state: [#{state.inspect}] STDOUT: [#{data.hex_dump.chomp}]")
                if state == :prompt
                  # Detect the common prompts
                  # someuser@somehost ... $  rootuser@somehost ... #  [someuser@somehost ...] $  [rootuser@somehost ...] #
                  prompt = data if data =~ /^\[*[\w\-\.]+@[\w\-\.]+.+\]*[\#\$]\s*$/
                  output_buffer << data
                  unless done_string.nil?
                    data.each_line { |l| return output_buffer if done_string == l.chomp }
                  end

                  if output_buffer[-prompt.length, prompt.length] == prompt
                    return output_buffer[0..(output_buffer.length - prompt.length)]
                  end
                end

                if state == :command_sent
                  cmd_rx << data
                  state = :prompt if cmd_rx == "#{cmd}\r\n"
                end

                if state == :password_sent
                  prompt << data.lstrip
                  if data.strip =~ /\#/
                    $log&.debug("#{header} - Superuser Prompt detected: sending command #{cmd}")
                    channel.send_data("#{cmd}\n")
                    state = :command_sent
                  end
                end

                if state == :initial
                  prompt << data.lstrip
                  if data.strip =~ /[Pp]assword:/
                    prompt = ""
                    $log&.debug("#{header} - Password Prompt detected: sending su password")
                    channel.send_data("#{@su_password}\n")
                    state = :password_sent
                  end
                end
              end

              channel.on_extended_data do |_channel, _type, data|
                $log&.debug("#{header} - STDERR: #{data}")
                error_buffer << data
              end

              channel.on_request('exit-status') do |_channel, data|
                status = data.read_long
                $log&.debug("#{header} - STATUS: #{status}")
              end

              channel.on_request('exit-signal') do |_channel, data|
                signal = data.read_string
                $log&.debug("#{header} - SIGNAL: #{signal}")
              end

              channel.on_eof do |_channel|
                $log&.debug("#{header} - EOF RECEIVED")
              end

              channel.on_close do |_channel|
                error_buffer << prompt if [:initial, :password_sent].include?(state)
                $log&.debug("#{header} - Command: #{cmd}, exit status: #{status}")
                raise "#{header} - Command #{cmd}, exited with signal #{signal}" unless signal.nil?
                unless status.zero?
                  raise "#{header} - Command #{cmd}, exited with status #{status}" if error_buffer.empty?
                  raise "#{header} - Command #{cmd} failed: #{error_buffer}, status: #{status}"
                end
                return output_buffer
              end

              $log&.debug("#{header} - Command: [#{cmd_str}] started.")
              su_command = @su_user == 'root' ? "su -l\n" : "su -l #{@su_user}\n"

              channel.exec(su_command) do |chan, success|
                raise "#{header} - Could not execute command #{cmd}" unless success
                if stdin.present?
                  chan.send_data(stdin)
                  chan.eof!
                end
              end
            end
          end
          ssh.loop
        end
      end

      # Creates a local temporary file under /var/tmp with +cmd+ as its contents.
      # The tempfile name is the name of the command with "miq-" prepended and ".sh"
      # appended to the end.
      #
      # The end result is a string meant to be run via the suexec method. For example:
      #
      # "chmod 700 /var/tmp/miq-foo.sh; /var/tmp/miq-foo.sh; rm -f /var/tmp/miq-foo.sh
      #
      def temp_cmd_file(cmd)
        temp_remote_script = Tempfile.new(["miq-", ".sh"], "/var/tmp")
        temp_file          = temp_remote_script.path
        begin
          temp_remote_script.write(cmd)
          temp_remote_script.close
          remote_cmd = "chmod 700 #{temp_file}; #{temp_file}; rm -f #{temp_file}"
          yield(remote_cmd)
        ensure
          temp_remote_script.close!
        end
      end

      # Shortcut method that creates and yields a ManageIQ::SSH::Util object, with the +host+,
      # +remote_user+ and +remote_password+ options passed in as the first three
      # params to the constructor, while the +su_user+ and +su_password+ parameters
      # automatically set the corresponding :su_user and :su_password options. The
      # remaining options are passed normally.
      #
      # This method is functionally identical to the following code, except that it
      # yields itself (and nil).
      #
      #   ManageIQ::SSH::Util.new(host, remote_user, remote_password, {:su_user => su_user, :su_password => su_password})
      #
      def self.shell_with_su(host, remote_user, remote_password, su_user, su_password, options = {})
        options[:su_user], options[:su_password] = su_user, su_password
        ssu = new(host, remote_user, remote_password, options)
        yield(ssu, nil)
      end

      # Executes the provided +cmd+ using the exec or suexec method, depending on
      # whether or not the :su_user option is set. The +done_string+ and +stdin+
      # arguments are passed along to the appropriate method as well.
      #
      # In the case of suexec, escape characters are automatically removed from
      # the final output.
      #
      #--
      # The _shell argument appears to be an artifact that has been retained
      # over time for reasons that aren't immediately apparent.
      #
      def shell_exec(cmd, done_string = nil, _shell = nil, stdin = nil)
        return exec(cmd, done_string, stdin) if @su_user.nil?
        ret = suexec(cmd, done_string, stdin)
        # Remove escape character from the end of the line
        ret.sub!(/\e$/, '')
        ret
      end

      # Copies the remote +file_path+ to a local temporary file, and then
      # yields or returns a filehandle to the local temporary file.
      #--
      # Presumably this method was meant for use with the SCVMM provider
      # given the hardcoded name of the temporary file.
      #
      def file_open(file_path, perm = 'r')
        if block_given?
          Tempfile.open('miqscvmm') do |tf|
            tf.close
            get_file(file_path, tf.path)
            File.open(tf.path, perm) { |f| yield(f) }
          end
        else
          tf = Tempfile.open('miqscvmm')
          tf.close
          get_file(file_path, tf.path)
          File.open(tf.path, perm)
        end
      end

      # Returns whether or not the remote +filename+ exists.
      #
      def file_exists?(filename)
        shell_exec("test -f #{filename}")
      rescue
        false
      else
        true
      end

      # This method creates and yields an ssh object. If the :remember_host option
      # was set to true, it will record this host and key in the known hosts file
      # and retry once.
      #
      def run_session
        first_try = true

        begin
          Net::SSH.start(@host, @user, @options) do |ssh|
            yield(ssh)
          end
        rescue Net::SSH::HostKeyMismatch => err
          if remember_host? && first_try
            # Save fingerprint and try again
            first_try = false
            err.remember_host!
            retry
          else
            # Re-raise error
            raise err
          end
        end
      end
    end # Util
  end # SSH
end # ManageIQ