Class: ManageIQ::SSH::Util

Inherits:
Object
  • Object
show all
Defined in:
lib/manageiq/ssh/util.rb,
lib/manageiq/ssh/util/version.rb

Overview

Utility wrapper around the net-ssh library.

Constant Summary collapse

VERSION =

The version of the manageiq-ssh-util library

'0.2.0'.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(host, user, password = nil, options = {}) ⇒ Util

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.



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/manageiq/ssh/util.rb', line 56

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

Instance Attribute Details

#hostObject (readonly)

The name of the host provided to the constructor.



15
16
17
# File 'lib/manageiq/ssh/util.rb', line 15

def host
  @host
end

#optionsObject (readonly)

The options hash passed to the constructor.



18
19
20
# File 'lib/manageiq/ssh/util.rb', line 18

def options
  @options
end

#statusObject (readonly)

The exit status of the ssh command.



12
13
14
# File 'lib/manageiq/ssh/util.rb', line 12

def status
  @status
end

#userObject (readonly)

The username passed to the constructor.



21
22
23
# File 'lib/manageiq/ssh/util.rb', line 21

def user
  @user
end

Class Method Details

.shell_with_su(host, remote_user, remote_password, su_user, su_password, options = {}) {|ssu, nil| ... } ⇒ Object

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})

Yields:

  • (ssu, nil)


347
348
349
350
351
# File 'lib/manageiq/ssh/util.rb', line 347

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

Instance Method Details

#exec(cmd, done_string = nil, stdin = nil) ⇒ Object

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.



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/manageiq/ssh/util.rb', line 138

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

#file_exists?(filename) ⇒ Boolean

Returns whether or not the remote filename exists.

Returns:

  • (Boolean)


395
396
397
398
399
400
401
# File 'lib/manageiq/ssh/util.rb', line 395

def file_exists?(filename)
  shell_exec("test -f #{filename}")
rescue
  false
else
  true
end

#file_open(file_path, perm = 'r') ⇒ Object

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.



378
379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'lib/manageiq/ssh/util.rb', line 378

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

#get_file(from, to) ⇒ Object

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.



95
96
97
98
99
100
101
102
# File 'lib/manageiq/ssh/util.rb', line 95

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

#put_file(to, content = nil, path = nil) ⇒ Object

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.

Raises:

  • (ArgumentError)


110
111
112
113
114
115
116
117
118
# File 'lib/manageiq/ssh/util.rb', line 110

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

#remember_host?Boolean

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.

Returns:

  • (Boolean)


84
85
86
# File 'lib/manageiq/ssh/util.rb', line 84

def remember_host?
  !!@remember_host
end

#run_sessionObject

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.



407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'lib/manageiq/ssh/util.rb', line 407

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

#shell_exec(cmd, done_string = nil, _shell = nil, stdin = nil) ⇒ Object

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.



364
365
366
367
368
369
370
# File 'lib/manageiq/ssh/util.rb', line 364

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

#suexec(cmd_str, done_string = nil, stdin = nil) ⇒ Object

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.



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/manageiq/ssh/util.rb', line 209

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

#temp_cmd_file(cmd) ⇒ Object

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



323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/manageiq/ssh/util.rb', line 323

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