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. It is recommended to use \z to ensure you don't match the prompt too early.
  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
  cmd_rm_command_tail: "\n", # which format does the end of line return after a command has been submitted. Could be something like "ls\n" "ls\r\n" or "ls \n" (extra spaces)
  cmd_minimum_duration: 0, # how long do you want to wait/sleep after sending the command. After this waiting time, the output will be processed and the prompt will be searched.
  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
  terminal_chars_width: 320, # Sets and sends the terminal dimensions during the opening of the channel. It does not send a channel_request on change.
  terminal_chars_height: 120,                                          # See also https://github.com/net-ssh/net-ssh/blob/master/lib/net/ssh/connection/channel.rb#L220 section 'def request_pty'
  terminal_pixels_width: 1920,                                         # See also https://www.ietf.org/rfc/rfc4254.txt section pty-req and section window-change
  terminal_pixels_height: 1080, #
  terminal_term: nil,                                          # Sets the terminal term, usually xterm
  terminal_modes: nil                                          #
)
VERSION =
'1.9.2'

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



355
356
357
# File 'lib/net/ssh/cli.rb', line 355

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



402
403
404
405
# File 'lib/net/ssh/cli.rb', line 402

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, minimum_duration: cmd_minimum_duration, **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 purpose 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


265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/net/ssh/cli.rb', line 265

def cmd(command, pre_read: true, rm_prompt: cmd_rm_prompt, rm_command: cmd_rm_command, prompt: current_prompt, minimum_duration: cmd_minimum_duration, **opts)
  opts = opts.clone.merge(pre_read:, rm_prompt:, rm_command:, 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
  sleep(minimum_duration)
  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



286
287
288
# File 'lib/net/ssh/cli.rb', line 286

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

#current_promptObject

fancy prompt|prompt handling methods



157
158
159
# File 'lib/net/ssh/cli.rb', line 157

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



180
181
182
183
184
185
186
187
# File 'lib/net/ssh/cli.rb', line 180

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



253
254
255
256
# File 'lib/net/ssh/cli.rb', line 253

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

#hostObject Also known as: hostname, to_s



333
334
335
# File 'lib/net/ssh/cli.rb', line 333

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”



324
325
326
# File 'lib/net/ssh/cli.rb', line 324

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



329
330
331
# File 'lib/net/ssh/cli.rb', line 329

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



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

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



376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/net/ssh/cli.rb', line 376

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(terminal_options) 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
    process until channel
  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



75
76
77
78
79
80
81
82
83
# File 'lib/net/ssh/cli.rb', line 75

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



86
87
88
# File 'lib/net/ssh/cli.rb', line 86

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

#options=(opts) ⇒ Object



90
91
92
# File 'lib/net/ssh/cli.rb', line 90

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.



370
371
372
373
374
# File 'lib/net/ssh/cli.rb', line 370

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)


237
238
239
240
241
242
243
244
245
246
# File 'lib/net/ssh/cli.rb', line 237

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



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

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

#read_for(seconds:) ⇒ Object



248
249
250
251
# File 'lib/net/ssh/cli.rb', line 248

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%


217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/net/ssh/cli.rb', line 217

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?
        raise Error::ReadTillTimeout, "#{current_prompt.inspect} didn't match on #{stdout.inspect} within #{timeout}s" if timeout and soft_timeout < Time.now

        process
        sleep 0.01 # don't race for CPU
      end
    end
  end
  read
end

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



291
292
293
# File 'lib/net/ssh/cli.rb', line 291

def rm_command!(output, command, **opts)
  output[command + cmd_rm_command_tail] = "" if rm_command?(**opts) && output[command + cmd_rm_command_tail]
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/



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/net/ssh/cli.rb', line 299

def rm_prompt!(output, prompt: current_prompt, **opts)
  if rm_prompt?(**opts) && (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

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



344
345
346
347
348
349
350
# File 'lib/net/ssh/cli.rb', line 344

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



133
134
135
136
137
138
139
140
# File 'lib/net/ssh/cli.rb', line 133

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



118
119
120
121
122
# File 'lib/net/ssh/cli.rb', line 118

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

#with_named_prompt(name, &block) ⇒ 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”)



171
172
173
174
175
# File 'lib/net/ssh/cli.rb', line 171

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

  with_prompt(named_prompts[name], &block)
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”)



196
197
198
199
200
201
202
203
204
# File 'lib/net/ssh/cli.rb', line 196

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



143
144
145
# File 'lib/net/ssh/cli.rb', line 143

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