Class: BarkestSsh::SecureShell

Inherits:
Object
  • Object
show all
Defined in:
lib/barkest_ssh/secure_shell.rb

Overview

The SecureShell class is used to run an SSH session with a local or remote host.

This is a wrapper for Net::SSH that starts a shell session and executes a block. All of the output from the session is cached for later use as needed.

Constant Summary collapse

ShellError =

An error occurring within the SecureShell class aside from argument errors.

Class.new(StandardError)
ConnectionClosed =

An exception raised when a command requiring a connection is attempted after the connection has been closed.

Class.new(ShellError)
FailedToRequestPTY =

An exception raised when the SSH session fails to request a PTY.

Class.new(ShellError)
FailedToStartShell =

An exception raised when the SSH session fails to start a shell.

Class.new(ShellError)
FailedToExecute =

An exception raised when the SSH shell session fails to execute.

Class.new(ShellError)
LongSilence =

An exception raised when the shell session is silent too long.

Class.new(ShellError)
NonZeroExitCode =

A command exited with a non-zero status.

Class.new(ShellError)

Instance Method Summary collapse

Constructor Details

#initialize(options = {}, &block) ⇒ SecureShell

Creates a SecureShell session and executes the provided block.

You must provide a code block to run within the shell session, the session is closed before this returns.

Valid options:

  • host The name or IP address of the host to connect to. Defaults to ‘localhost’.

  • port The port on the host to connect to. Defaults to 22.

  • user The user to login with.

  • password The password to login with.

  • prompt The prompt used to determine when processes finish execution. Defaults to ‘~~#’, but if that doesn’t work for some reason because it is valid output from one or more commands, you can change it to something else. It must be unique and cannot contain certain characters. The characters you should avoid are !, $, , /, “, and ‘ because no attempt is made to escape them and the resulting prompt can very easily become something else entirely. If they are provided, they will be replaced to protect the shell from getting stuck.

  • silence_wait The number of seconds to wait when the shell is not sending back data to send a newline. This can help battle background tasks burying the prompt, but it might not play nice with long-running foreground tasks. The default is 5 seconds, if you notice problems, set this to a higher value, or 0 to disable. During extended silence, the first time this value elapses, the shell will send the newline, the second time the shell will error out.

  • replace_cr The string to replace stand-alone CR characters with. The default is an empty string (ie - remove them). You may also want to replace with a LF character instead, which is the behavior taken when a CR+ LF sequence is encountered. A space followed by a standalone CR is treated differently since these seem to occur when the terminal ouput wraps. In these cases, the SPACE + CR sequence is simply removed.

  • retrieve_exit_code Version 1.1.10 introduces support for grabbing the exit code from the last command and then performing an action. The default value is true, but if you set this to false then the shell will not retrieve the exit codes automatically.

  • on_non_zero_exit_code If the exit code is non-zero, the default behavior (to remain compatible with prior versions) is to ignore the exit code. You can also set this to :raise_error to raise the NonZeroExitCode error.

  • filter_password As a convenience, if this is set to true (the default), then any text matching the configured password will be replaced with a series of asterisks in the output.

SecureShell.new(
    host: '10.10.10.10',
    user: 'somebody',
    password: 'super-secret'
) do |shell|
  shell.exec('cd /usr/local/bin')
  user_bin_files = shell.exec('ls -A1').split('\n')
  @app_is_installed = user_bin_files.include?('my_app')
end

Raises:

  • (ArgumentError)


97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
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
# File 'lib/barkest_ssh/secure_shell.rb', line 97

def initialize(options = {}, &block)
  options ||= {}
  @options = {
      host: options[:host] || 'localhost',
      port: options[:port] || 22,
      user: options[:user],
      password: options[:password],
      prompt: (options[:prompt].to_s.strip == '') ? '~~#' : options[:prompt],
      silence_wait: (options[:silence_wait] || 5),
      replace_cr: options[:replace_cr].to_s,
      retrieve_exit_code: options[:retrieve_exit_code].nil? ? true : options[:retrieve_exit_code],
      on_non_zero_exit_code: options[:on_non_zero_exit_code] ? options[:on_non_zero_exit_code].to_s.to_sym : :ignore,
      filter_password: options[:filter_password].nil? ? true : options[:filter_password],
  }

  raise ArgumentError.new('Missing block.') unless block_given?
  raise ArgumentError.new('Missing host.') if @options[:host].to_s.strip == ''
  raise ArgumentError.new('Missing user.') if @options[:user].to_s.strip == ''
  raise ArgumentError.new('Missing password.') if @options[:password].to_s.strip == ''
  raise ArgumentError.new('Missing prompt.') if @options[:prompt].to_s.strip == ''
  raise ArgumentError.new('Invalid option for on_non_zero_exit_code.') unless [:ignore, :raise_error].include?(@options[:on_non_zero_exit_code])

  @options[:prompt] = @options[:prompt]
                          .gsub('!', '#')
                          .gsub('$', '#')
                          .gsub('\\', '.')
                          .gsub('/', '.')
                          .gsub('"', '-')
                          .gsub('\'', '-')

  executed = false

  @last_exit_code = 0
  @sftp = nil
  Net::SSH.start(
      @options[:host],
      @options[:user],
      password: @options[:password],
      port: @options[:port],
      non_interactive: true,
  ) do |ssh|
    @ssh = ssh
    ssh.open_channel do |ssh_channel|
      ssh_channel.request_pty do |pty_channel, pty_success|
        raise FailedToRequestPTY.new('Failed to request PTY.') unless pty_success

        pty_channel.send_channel_request('shell') do |_, shell_success|
          raise FailedToStartShell.new('Failed to start shell.') unless shell_success

          # cache the channel pointer and start buffering the input.
          @channel = pty_channel
          buffer_input

          # give the shell a chance to catch up and initialize fully.
          sleep 0.25

          # set the shell prompt so that we can determine when processes end.
          # does not work with background processes since we are looking for
          # the shell to send us this when it is ready for more input.
          # a background process can easily bury the prompt and then we are stuck in a loop.
          exec "PS1=\"#{@options[:prompt]}\""

          block.call(self)

          executed = true

          # send the exit command and remove the channel pointer.
          quit
          @channel = nil
        end
      end
      ssh_channel.wait
    end
  end

  @ssh = nil

  if @sftp
    @sftp.session.close
    @sftp = nil
  end

  # remove the cached user and password.
  options.delete(:user)
  options.delete(:password)

  raise FailedToExecute.new('Failed to execute shell.') unless executed
end

Instance Method Details

#combined_outputObject

Gets both the standard output and error output from the session.

The prompts will be included in the combined output. There is no attempt to differentiate error output from standard output.

This is essentially the definitive log for the session.

All line endings are converted to LF characters, so you will not encounter or need to search for CRLF or CR sequences.



386
387
388
# File 'lib/barkest_ssh/secure_shell.rb', line 386

def combined_output
  @stdcomb || ''
end

#download(remote_file, local_file) ⇒ Object

Uses SFTP to download a single file from the host.

Raises:



329
330
331
332
# File 'lib/barkest_ssh/secure_shell.rb', line 329

def download(remote_file, local_file)
  raise ConnectionClosed.new('Connection is closed.') unless @ssh
  sftp.download!(remote_file, local_file)
end

#exec(command, options = {}, &block) ⇒ Object

Executes a command during the shell session.

If called outside of the new block, this will raise an error.

The command is the command to execute in the shell.

The options parameter can include the following keys.

  • The :on_non_zero_exit_code option can be :default, :ignore, or :raise_error.

If provided, the block is a chunk of code that will be processed every time the shell receives output from the program. If the block returns a string, the string will be sent to the shell. This can be used to monitor processes or monitor and interact with processes. The block is optional.

shell.exec('sudo -p "password:" nginx restart') do |data,type|
  return 'super-secret' if /password:$/.match(data)
  nil
end

Raises:



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
# File 'lib/barkest_ssh/secure_shell.rb', line 224

def exec(command, options={}, &block)
  raise ConnectionClosed.new('Connection is closed.') unless @channel

  options = {
      on_non_zero_exit_code: :default
  }.merge(options || {})

  options[:on_non_zero_exit_code] = @options[:on_non_zero_exit_code] if options[:on_non_zero_exit_code] == :default

  push_buffer # store the current buffer and start a fresh buffer

  # buffer while also passing data to the supplied block.
  if block_given?
    buffer_input( &block )
  end

  # send the command and wait for the prompt to return.
  @channel.send_data command + "\n"
  wait_for_prompt

  # return buffering to normal.
  if block_given?
    buffer_input
  end

  # get the output from the command, minus the trailing prompt.
  ret = command_output(command)

  # restore the original buffer and merge the output from the command.
  pop_merge_buffer

  if @options[:retrieve_exit_code]
    # get the exit code for the command.
    push_buffer
    retrieve_command = 'echo $?'
    @channel.send_data retrieve_command + "\n"
    wait_for_prompt
    @last_exit_code = command_output(retrieve_command).strip.to_i
    # restore the original buffer and discard the output from this command.
    pop_discard_buffer

    # if we are expected to raise an error, do so.
    if options[:on_non_zero_exit_code] == :raise_error
      raise NonZeroExitCode.new("Exit code was #{@last_exit_code}.") unless @last_exit_code == 0
    end
  end

  ret
end

#exec_ignore(command, &block) ⇒ Object

Wrapper for exec that will ignore non-zero exit codes.



194
195
196
# File 'lib/barkest_ssh/secure_shell.rb', line 194

def exec_ignore(command, &block)
  exec command, on_non_zero_exit_code: :ignore, &block
end

#exec_raise(command, &block) ⇒ Object

Wrapper for exec that will raise an error on non-zero exit codes.



200
201
202
# File 'lib/barkest_ssh/secure_shell.rb', line 200

def exec_raise(command, &block)
  exec command, on_non_zero_exit_code: :raise_error, &block
end

#last_exit_codeObject

Gets the last exit code.



188
189
190
# File 'lib/barkest_ssh/secure_shell.rb', line 188

def last_exit_code
  @last_exit_code || 0
end

#read_file(remote_file) ⇒ Object

Uses SFTP to read the contents of a single file.

Returns the contents of the file.

Raises:



338
339
340
341
# File 'lib/barkest_ssh/secure_shell.rb', line 338

def read_file(remote_file)
  raise ConnectionClosed.new('Connection is closed.') unless @ssh
  sftp.download!(remote_file)
end

#stderrObject

Gets the error output from the session.

All line endings are converted to LF characters, so you will not encounter or need to search for CRLF or CR sequences.



371
372
373
# File 'lib/barkest_ssh/secure_shell.rb', line 371

def stderr
  @stderr || ''
end

#stdoutObject

Gets the standard output from the session.

The prompts are stripped from the standard ouput as they are encountered. So this will be a list of commands with their output.

All line endings are converted to LF characters, so you will not encounter or need to search for CRLF or CR sequences.



361
362
363
# File 'lib/barkest_ssh/secure_shell.rb', line 361

def stdout
  @stdout || ''
end

#sudo_exec(command, options = {}, &block) ⇒ Object

Executes a command using sudo during the shell session.

This is a wrapper around exec that attempts to run the command as root. It provides the configured user’s password if/when prompted.

See exec for more information.



293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/barkest_ssh/secure_shell.rb', line 293

def sudo_exec(command, options = {}, &block)
  sudo_prompt = '[sp:'
  sudo_match = /(\r|\n)\[sp\:$/
  sudo_strip = /\[sp\:\n/
  ret = exec("sudo -p \"#{sudo_prompt}\" bash -c \"#{command.gsub('"', '\\"')}\"", options) do |data,type|
    test_data = data.to_s
    desired_length = sudo_prompt.length + 1 # prefix a NL before the prompt.

    # pull from the current stdout to get the full test data, but only if we received some new data.
    if test_data.length > 0 && test_data.length < desired_length
      test_data = stdout[-desired_length..-1].to_s
    end

    if sudo_match.match(test_data)
      @options[:password]
    else
      if block
        block.call(data, type)
      else
        nil
      end
    end
  end
  # remove the sudo prompts.
  ret.gsub(sudo_strip, '')
end

#sudo_exec_ignore(command, &block) ⇒ Object

Wrapper for sudo_exec that will ignore non-zero exit codes.



276
277
278
# File 'lib/barkest_ssh/secure_shell.rb', line 276

def sudo_exec_ignore(command, &block)
  sudo_exec command, on_non_zero_exit_code: :ignore, &block
end

#sudo_exec_raise(command, &block) ⇒ Object

Wrapper for sudo_exec that will raise an error on non-zero exit codes.



282
283
284
# File 'lib/barkest_ssh/secure_shell.rb', line 282

def sudo_exec_raise(command, &block)
  sudo_exec command, on_non_zero_exit_code: :raise_error, &block
end

#upload(local_file, remote_file) ⇒ Object

Uses SFTP to upload a single file to the host.

Raises:



322
323
324
325
# File 'lib/barkest_ssh/secure_shell.rb', line 322

def upload(local_file, remote_file)
  raise ConnectionClosed.new('Connection is closed.') unless @ssh
  sftp.upload!(local_file, remote_file)
end

#write_file(remote_file, data) ⇒ Object

Uses SFTP to write data to a single file.

Raises:



345
346
347
348
349
350
# File 'lib/barkest_ssh/secure_shell.rb', line 345

def write_file(remote_file, data)
  raise ConnectionClosed.new('Connection is closed.') unless @ssh
  sftp.file.open(remote_file, 'w') do |f|
    f.write data
  end
end