Module: Net::SSH::CLI

Included in:
Session
Defined in:
lib/net/ssh/cli.rb,
lib/net/ssh/cli/version.rb

Defined Under Namespace

Classes: Error, Session

Constant Summary collapse

OPTIONS =
ActiveSupport::HashWithIndifferentAccess.new(
  default_prompt:            /\n?^(\S+@.*)\z/,                             # the default prompt to search for
  cmd_rm_prompt:             false,                                        # whether the prompt should be removed in the output of #cmd
  cmd_rm_command:            false,                                        # whether the given command should be removed in the output of #cmd
  run_impact:                false,                                        # whether to run #impact commands. This might align with testing|development|production. example #impact("reboot")
  read_till_timeout:         nil,                                          # timeout for #read_till to find the match
  read_till_hard_timeout:    nil,                                          # hard timeout for #read_till to find the match using Timeout.timeout(hard_timeout) {}. Might creates unpredicted sideffects
  read_till_hard_timeout_factor: 1.2,                                      # hard timeout factor in case read_till_hard_timeout is true
  named_prompts:             ActiveSupport::HashWithIndifferentAccess.new, # you can used named prompts for #with_prompt {} 
  before_cmd_procs:          ActiveSupport::HashWithIndifferentAccess.new, # procs to call before #cmd 
  after_cmd_procs:           ActiveSupport::HashWithIndifferentAccess.new, # procs to call after  #cmd
  before_on_stdout_procs:    ActiveSupport::HashWithIndifferentAccess.new, # procs to call before data arrives from the underlying connection 
  after_on_stdout_procs:     ActiveSupport::HashWithIndifferentAccess.new, # procs to call after  data arrives from the underlying connection
  before_on_stdin_procs:     ActiveSupport::HashWithIndifferentAccess.new, # procs to call before data is sent to the underlying channel 
  after_on_stdin_procs:      ActiveSupport::HashWithIndifferentAccess.new, # procs to call after  data is sent to the underlying channel
  before_open_channel_procs: ActiveSupport::HashWithIndifferentAccess.new, # procs to call before opening a channel 
  after_open_channel_procs:  ActiveSupport::HashWithIndifferentAccess.new, # procs to call after  opening a channel, for example you could call #detect_prompt or #read_till
  open_channel_timeout:      nil,                                          # timeout to open the channel
  net_ssh_options:           ActiveSupport::HashWithIndifferentAccess.new, # a wrapper for options to pass to Net::SSH.start in case net_ssh is undefined
  process_time:              0.00001,                                      # how long #process is processing net_ssh#process or sleeping (waiting for something)
  background_processing:     false,                                        # default false, whether the process method maps to the underlying net_ssh#process or the net_ssh#process happens in a separate loop
  on_stdout_processing:      100,                                          # whether to optimize the on_stdout performance by calling #process #optimize_on_stdout-times in case more data arrives
  sleep_procs:               ActiveSupport::HashWithIndifferentAccess.new, # procs to call instead of Kernel.sleep(), perfect for async hooks
)
VERSION =
'1.6.0'

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#channelObject

Returns the value of attribute channel.



40
41
42
# File 'lib/net/ssh/cli.rb', line 40

def channel
  @channel
end

#loggerObject

Returns the value of attribute logger.



40
41
42
# File 'lib/net/ssh/cli.rb', line 40

def logger
  @logger
end

#net_sshObject Also known as: proxy

NET::SSH



348
349
350
# File 'lib/net/ssh/cli.rb', line 348

def net_ssh
  @net_ssh
end

#new_dataObject

Returns the value of attribute new_data.



40
41
42
# File 'lib/net/ssh/cli.rb', line 40

def new_data
  @new_data
end

#process_countObject

Returns the value of attribute process_count.



40
41
42
# File 'lib/net/ssh/cli.rb', line 40

def process_count
  @process_count
end

#stdoutObject

Returns the value of attribute stdout.



40
41
42
# File 'lib/net/ssh/cli.rb', line 40

def stdout
  @stdout
end

Class Method Details

.start(**opts) ⇒ Object

Example net_ssh = Net::SSH.start(“localhost”) net_ssh_cli = Net::SSH::CLI.start(net_ssh: net_ssh) net_ssh_cli.cmd “cat /etc/passwd”

> “root:x:0:0:root:/root:/bin/bashn…”



28
29
30
# File 'lib/net/ssh/cli.rb', line 28

def self.start(**opts)
  Net::SSH::CLI::Session.new(**opts)
end

Instance Method Details

#close_channelObject



395
396
397
398
# File 'lib/net/ssh/cli.rb', line 395

def close_channel
  net_ssh&.cleanup_channel(channel) if channel
  self.channel = nil
end

#cmd(command, pre_read: true, rm_prompt: cmd_rm_prompt, rm_command: cmd_rm_command, prompt: current_prompt, **opts) ⇒ Object Also known as: command, exec

send a command and get the output as return value

  1. sends the given command to the ssh connection channel

  2. continues to process the ssh connection until the prompt is found in the stdout

  3. prepares the output using your callbacks

  4. returns the output of your command

Hint: ‘read’ first on purpuse as a feature. once you cmd you ignore what happend before. otherwise use read|write directly.

this should avoid many horrible state issues where the prompt is not the last prompt


257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/net/ssh/cli.rb', line 257

def cmd(command, pre_read: true, rm_prompt: cmd_rm_prompt, rm_command: cmd_rm_command, prompt: current_prompt, **opts)
  opts = opts.clone.merge(pre_read: pre_read, rm_prompt: rm_prompt, rm_command: rm_command, prompt: prompt)
  if pre_read
    pre_read_data = read
    logger.debug { "#cmd ignoring pre-command output: #{pre_read_data.inspect}" } if pre_read_data.present?
  end
  before_cmd_procs.each { |_name, a_proc| instance_eval(&a_proc) }
  write_n command
  output = read_till(**opts)
  rm_prompt!(output, **opts)
  rm_command!(output, command, **opts)
  after_cmd_procs.each { |_name, a_proc| instance_eval(&a_proc) }
  output
rescue Error::ReadTillTimeout => error
  raise Error::CMD, "#{error.message} after cmd #{command.inspect} was sent"
end

#cmds(*commands, **opts) ⇒ Object Also known as: commands

Execute multiple cmds, see #cmd



277
278
279
# File 'lib/net/ssh/cli.rb', line 277

def cmds(*commands, **opts)
  commands.flatten.map { |command| [command, cmd(command, **opts)] }
end

#current_promptObject

fancy prompt|prompt handling methods



147
148
149
# File 'lib/net/ssh/cli.rb', line 147

def current_prompt
  with_prompts[-1] || default_prompt
end

#detect_prompt(seconds: 3) ⇒ Object

tries to detect the prompt sends a “n”, waits for a X seconds, and uses the last line as prompt this won’t work reliable if the prompt changes during the session



172
173
174
175
176
177
178
179
# File 'lib/net/ssh/cli.rb', line 172

def detect_prompt(seconds: 3)
  write_n
  process(seconds)
  self.default_prompt = read[/\n?^.*\z/]
  raise Error::PromptDetection, "couldn't detect a prompt" unless default_prompt.present?

  default_prompt
end

#dialog(command, prompt, **opts) ⇒ Object



245
246
247
248
# File 'lib/net/ssh/cli.rb', line 245

def dialog(command, prompt, **opts)
  opts = opts.clone.merge(prompt: prompt)
  cmd(command, **opts)
end

#hostObject Also known as: hostname, to_s



326
327
328
# File 'lib/net/ssh/cli.rb', line 326

def host
  @net_ssh&.host
end

#impact(command, **opts) ⇒ Object

the same as #cmd but it will only run the command if the option run_impact is set to true. this can be used for commands which you might not want to run in development|testing mode but in production cli.impact(“reboot”)

> “skip: reboot”

cli.run_impact = true cli.impact(“reboot”)

> “system is going to reboot NOW”



317
318
319
# File 'lib/net/ssh/cli.rb', line 317

def impact(command, **opts)
  run_impact? ? cmd(command, **opts) : "skip: #{command.inspect}"
end

#impacts(*commands, **opts) ⇒ Object

same as #cmds but for #impact instead of #cmd



322
323
324
# File 'lib/net/ssh/cli.rb', line 322

def impacts(*commands, **opts)
  commands.flatten.map { |command| [command, impact(command, **opts)] }
end

#initialize(**opts) ⇒ Object



32
33
34
35
36
37
38
# File 'lib/net/ssh/cli.rb', line 32

def initialize(**opts)
  options.merge!(opts)
  self.net_ssh = options.delete(:net_ssh)
  self.logger = options.delete(:logger) || Logger.new(STDOUT, level: Logger::WARN)
  self.process_count = 0
  @new_data = String.new
end

#on_stdout(new_data) ⇒ Object



114
115
116
117
118
119
120
121
# File 'lib/net/ssh/cli.rb', line 114

def on_stdout(new_data)
  self.new_data = new_data
  before_on_stdout_procs.each { |_name, a_proc| instance_eval(&a_proc) }
  stdout << new_data
  after_on_stdout_procs.each { |_name, a_proc| instance_eval(&a_proc) }
  optimise_stdout_processing
  stdout
end

#open_channelObject

cli_channel



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/net/ssh/cli.rb', line 369

def open_channel # cli_channel
  before_open_channel_procs.each { |_name, a_proc| instance_eval(&a_proc) }
  ::Timeout.timeout(open_channel_timeout, Error::OpenChannelTimeout) do
    net_ssh.open_channel do |new_channel|
      logger.debug 'channel is open'
      self.channel = new_channel
      new_channel.request_pty do |_ch, success|
        raise Error::Pty, "#{host || ip} Failed to open ssh pty" unless success
      end
      new_channel.send_channel_request('shell') do |_ch, success|
        raise Error::RequestShell, 'Failed to open ssh shell' unless success
      end
      new_channel.on_data do |_ch, data|
        on_stdout(data)
      end
      # new_channel.on_extended_data do |_ch, type, data| end
      # new_channel.on_close do end
    end
    until channel do process end
  end
  logger.debug 'channel is ready, running callbacks now'
  after_open_channel_procs.each { |_name, a_proc| instance_eval(&a_proc) }
  process
  self
end

#optionsObject



67
68
69
70
71
72
73
74
75
# File 'lib/net/ssh/cli.rb', line 67

def options
  @options ||= begin
    opts = OPTIONS.clone
    opts.each do |key, value|
      opts[key] = value.clone if value.is_a?(Hash)
    end
    opts
  end
end

#options!(**opts) ⇒ Object

don’t even think about nesting hashes here



78
79
80
# File 'lib/net/ssh/cli.rb', line 78

def options!(**opts)
  options.merge!(opts)
end

#options=(opts) ⇒ Object



82
83
84
# File 'lib/net/ssh/cli.rb', line 82

def options=(opts)
  @options = ActiveSupport::HashWithIndifferentAccess.new(opts)
end

#process(time = process_time) ⇒ Object

have a deep look at the source of Net::SSH session#process github.com/net-ssh/net-ssh/blob/dd13dd44d68b7fa82d4ca9a3bbe18e30c855f1d2/lib/net/ssh/connection/session.rb#L227 session#loop github.com/net-ssh/net-ssh/blob/dd13dd44d68b7fa82d4ca9a3bbe18e30c855f1d2/lib/net/ssh/connection/session.rb#L179 because the (cli) channel stays open, we always need to ensure that the ssh layer gets “processed” further. This can be done inside here automatically or outside in a separate event loop for the net_ssh connection.



363
364
365
366
367
# File 'lib/net/ssh/cli.rb', line 363

def process(time = process_time)
  background_processing? ? sleep(time) : net_ssh.process(time)
rescue IOError => error
  raise Error, error.message
end

#prompt_in_stdout?Boolean

Returns:

  • (Boolean)


229
230
231
232
233
234
235
236
237
238
# File 'lib/net/ssh/cli.rb', line 229

def prompt_in_stdout?
  case current_prompt
  when Regexp
    !!stdout[current_prompt]
  when String
    stdout.include?(current_prompt)
  else
    raise Net::SSH::CLI::Error, "prompt/current_prompt is not a String/Regex #{current_prompt.inspect}"
  end
end

#readObject



137
138
139
140
141
142
# File 'lib/net/ssh/cli.rb', line 137

def read
  process
  var = stdout!
  logger.debug { "#read: \n#{var}" }
  var
end

#read_for(seconds:) ⇒ Object



240
241
242
243
# File 'lib/net/ssh/cli.rb', line 240

def read_for(seconds:)
  process(seconds)
  read
end

#read_till(prompt: current_prompt, timeout: read_till_timeout, hard_timeout: read_till_hard_timeout, hard_timeout_factor: read_till_hard_timeout_factor, **_opts) ⇒ Object

continues to process the ssh connection till #stdout matches the given prompt. might raise a timeout error if a soft/hard timeout is given be carefull when using the hard_timeout, this is using the dangerous Timeout.timeout this gets really slow on large outputs, since the prompt will be searched in the whole output. Use z in the regex if possible

Optional named arguments:

- prompt: expected to be a regex
- timeout: nil or a number
- hard_timeout: nil, true, or a number
- hard_timeout_factor: nil, true, or a number
-   when hard_timeout == true, this will set the hard_timeout as (read_till_hard_timeout_factor * read_till_timeout), defaults to 1.2 = +20%


209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/net/ssh/cli.rb', line 209

def read_till(prompt: current_prompt, timeout: read_till_timeout, hard_timeout: read_till_hard_timeout, hard_timeout_factor: read_till_hard_timeout_factor, **_opts)
  raise Error::UndefinedMatch, 'no prompt given or default_prompt defined' unless prompt
  hard_timeout = (read_till_hard_timeout_factor * timeout) if timeout and hard_timeout == true
  hard_timeout = nil if hard_timeout == true

  with_prompt(prompt) do
    ::Timeout.timeout(hard_timeout, Error::ReadTillTimeout, "#{current_prompt.inspect} didn't match on #{stdout.inspect} within #{hard_timeout}s") do
      soft_timeout = Time.now + timeout if timeout
      until prompt_in_stdout? do
        if timeout and soft_timeout < Time.now
          raise Error::ReadTillTimeout, "#{current_prompt.inspect} didn't match on #{stdout.inspect} within #{timeout}s"
        end
        process
        sleep 0.01 # don't race for CPU
      end
    end
  end
  read
end

#rm_command!(output, command, **opts) ⇒ Object



282
283
284
# File 'lib/net/ssh/cli.rb', line 282

def rm_command!(output, command, **opts)
  output[command + "\n"] = '' if rm_command?(**opts) && output[command + "\n"]
end

#rm_prompt!(output, prompt: current_prompt, **opts) ⇒ Object

removes the prompt from the given output prompt should contain a named match ‘prompt’ /(?<prompt>.something.)z/ for backwards compatibility it also tries to replace the first match of the prompt /(something)z/ it removes the whole match if no matches are given /somethingz/



290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/net/ssh/cli.rb', line 290

def rm_prompt!(output, prompt: current_prompt, **opts)
  if rm_prompt?(**opts)
    if output[prompt]
      case prompt
      when String then output[prompt] = ''
      when Regexp
        if prompt.names.include?("prompt")
          output[prompt, "prompt"] = ''
        else
          begin
            output[prompt, 1] = ''
          rescue IndexError
            output[prompt] = ''
          end
        end
      end
    end
  end
end

#sleep(duration) ⇒ Object

if #sleep_procs are set, they will be called instead of Kernel.sleep great for async .sleep_procs = proc do |duration| async_reactor.sleep(duration) end

cli.sleep(1)



337
338
339
340
341
342
343
# File 'lib/net/ssh/cli.rb', line 337

def sleep(duration)
  if sleep_procs.any?
    sleep_procs.each { |_name, a_proc| instance_exec(duration, &a_proc) }
  else
    Kernel.sleep(duration)
  end
end

#stdin(content = String.new) ⇒ Object Also known as: write



123
124
125
126
127
128
129
130
# File 'lib/net/ssh/cli.rb', line 123

def stdin(content = String.new)
  logger.debug { "#write #{content.inspect}" }
  before_on_stdin_procs.each { |_name, a_proc| instance_eval(&a_proc) }
  channel.send_data content
  process
  after_on_stdin_procs.each { |_name, a_proc| instance_eval(&a_proc) }
  content
end

#stdout!Object



108
109
110
111
112
# File 'lib/net/ssh/cli.rb', line 108

def stdout!
  var = stdout
  self.stdout = String.new
  var
end

#with_named_prompt(name) ⇒ Object

run something with a different named prompt

named_prompts = /(?<prompt>nroot)z/

with_named_prompt(“root”) do

cmd("sudo -i")
cmd("cat /etc/passwd")

end cmd(“exit”)



161
162
163
164
165
166
167
# File 'lib/net/ssh/cli.rb', line 161

def with_named_prompt(name)
  raise Error::UndefinedMatch, "unknown named_prompt #{name}" unless named_prompts[name]

  with_prompt(named_prompts[name]) do
    yield
  end
end

#with_prompt(prompt) ⇒ Object

run something with a different prompt

with_prompt(/(?<prompt>nroot)z/) do

cmd("sudo -i")
cmd("cat /etc/passwd")

end cmd(“exit”)



188
189
190
191
192
193
194
195
196
# File 'lib/net/ssh/cli.rb', line 188

def with_prompt(prompt)
  logger.debug { "#with_prompt: #{current_prompt.inspect} => #{prompt.inspect}" }
  with_prompts << prompt
  yield
  prompt
ensure
  with_prompts.delete_at(-1)
  logger.debug { "#with_prompt: => #{current_prompt.inspect}" }
end

#write_n(content = String.new) ⇒ Object



133
134
135
# File 'lib/net/ssh/cli.rb', line 133

def write_n(content = String.new)
  write content + "\n"
end