Class: Remotus::SshConnection

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Defined in:
lib/remotus/ssh_connection.rb

Overview

Class representing an SSH connection to a host

Defined Under Namespace

Classes: GatewayConnection

Constant Summary collapse

REMOTE_PORT =

Standard SSH remote port

22
KEEPALIVE_INTERVAL =

Standard SSH keepalive interval

300
DEFAULT_RETRIES =

Number of default retries

8
BASE_CONNECT_OPTIONS =

Base options for new SSH connections

{ non_interactive: true, keepalive: true, keepalive_interval: KEEPALIVE_INTERVAL }.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(host, port = REMOTE_PORT, host_pool: nil) ⇒ SshConnection

Creates an SshConnection

Parameters:

  • host (String)

    hostname

  • port (Integer) (defaults to: REMOTE_PORT)

    remote port

  • host_pool (Remotus::HostPool) (defaults to: nil)

    associated host pool To connect to a host via IP, the following metadata entry can be provided to the host pool:

    :ip
    

    To configure the gateway, the following metadata entries can be provided to the host pool:

    :gateway_host
    :gateway_port
    :gateway_metadata
    :gateway_ip
    

    These function similarly to the host, port, host_pool metadata, and ip fields.



90
91
92
93
94
95
# File 'lib/remotus/ssh_connection.rb', line 90

def initialize(host, port = REMOTE_PORT, host_pool: nil)
  Remotus.logger.debug { "Creating SshConnection #{object_id} for #{host}" }
  @host = host
  @port = port
  @host_pool = host_pool
end

Instance Attribute Details

#hostString (readonly)

Returns host hostname.

Returns:



32
33
34
# File 'lib/remotus/ssh_connection.rb', line 32

def host
  @host
end

#host_poolRemotus::HostPool (readonly)

Returns host_pool associated host pool.

Returns:



35
36
37
# File 'lib/remotus/ssh_connection.rb', line 35

def host_pool
  @host_pool
end

#portInteger (readonly)

Returns Remote port.

Returns:

  • (Integer)

    Remote port



29
30
31
# File 'lib/remotus/ssh_connection.rb', line 29

def port
  @port
end

Instance Method Details

#base_connectionNet::SSH::Connection::Session

Retrieves/creates the base SSH connection for the host If the base connection already exists, the existing connection will be retrieved

The SSH connection will be the same whether it is retrieved via base_connection or connection.

Returns:

  • (Net::SSH::Connection::Session)

    base SSH remote connection



114
115
116
# File 'lib/remotus/ssh_connection.rb', line 114

def base_connection
  connection
end

#closeObject

Closes the current SSH connection if it is active



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/remotus/ssh_connection.rb', line 161

def close
  Remotus.logger.debug { "Closing SSH connection." }

  begin
    @connection&.close
  rescue StandardError => e
    Remotus.logger.warn { "Failed to close existing SSH connection with error #{e}" }
  end

  begin
    @gateway&.connection&.shutdown! if via_gateway?
  rescue StandardError => e
    Remotus.logger.warn { "Failed to close existing SSH gateway connection with error #{e}" }
  end

  Remotus.logger.debug { "Setting @gateway and @connection to nil." }

  @gateway = nil
  @connection = nil
end

#connectionNet::SSH::Connection::Session

Retrieves/creates the SSH connection for the host If the connection already exists, the existing connection will be retrieved

Returns:

  • (Net::SSH::Connection::Session)

    remote connection



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
# File 'lib/remotus/ssh_connection.rb', line 124

def connection
  return @connection unless restart_connection?

  # Close any active connections
  close

  target_cred = Remotus::Auth.credential(self)

  Remotus.logger.debug { "Initializing SSH connection to #{target_cred.user}@#{@host}:#{@port}" }

  target_options = BASE_CONNECT_OPTIONS.dup
  target_options[:password] = target_cred.password if target_cred.password
  target_options[:keys] = [target_cred.private_key] if target_cred.private_key
  target_options[:key_data] = [target_cred.private_key_data] if target_cred.private_key_data
  target_options[:port] = @port || REMOTE_PORT

  if via_gateway?
    @gateway = GatewayConnection.new(@host_pool[:gateway_host], @host_pool[:gateway_port], @host_pool[:gateway_metadata])
    gateway_cred = Remotus::Auth.credential(@gateway)
    gateway_options = BASE_CONNECT_OPTIONS.dup
    gateway_options[:port] = @gateway.port || REMOTE_PORT
    gateway_options[:password] = gateway_cred.password if gateway_cred.password
    gateway_options[:keys] = [gateway_cred.private_key] if gateway_cred.private_key
    gateway_options[:key_data] = [gateway_cred.private_key_data] if gateway_cred.private_key_data

    Remotus.logger.debug { "Initializing SSH gateway connection to #{gateway_cred.user}@#{@gateway.host}:#{gateway_options[:port]}" }

    @gateway.connection = Net::SSH::Gateway.new(remote_gateway_host, gateway_cred.user, **gateway_options)
    @connection = @gateway.connection.ssh(remote_host, target_cred.user, **target_options)
  else
    @connection = Net::SSH.start(remote_host, target_cred.user, **target_options)
  end
end

#download(remote_path, local_path = nil, options = {}) ⇒ String

Downloads a file from the remote host to the local host

Parameters:

  • remote_path (String)

    remote path to download the file from (source)

  • local_path (String) (defaults to: nil)

    local path to download the file to (destination) if local_path is nil, the file's content will be returned

  • options (Hash) (defaults to: {})

    download options

Options Hash (options):

  • :sudo (Boolean)

    whether to run the download with sudo (defaults to false)

  • :retries (Integer)

    number of times to retry a closed connection (defaults to 2)

Returns:

  • (String)

    local path or file content (if local_path is nil)



384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/remotus/ssh_connection.rb', line 384

def download(remote_path, local_path = nil, options = {})
  # Support short calling syntax (download("remote_path", option1: 123, option2: 234))
  if local_path.is_a?(Hash)
    options = local_path
    local_path = nil
  end

  # Sudo prep
  if options[:sudo]
    # Must first copy the file to an accessible directory for the login user to download it
    user_remote_path = sudo_remote_file_path(remote_path)
    Remotus.logger.debug { "Sudo enabled, copying file from #{@host}:#{remote_path} to #{@host}:#{user_remote_path}" }
    run("/bin/cp -f '#{remote_path}' '#{user_remote_path}' && chown #{Remotus::Auth.credential(self).user} '#{user_remote_path}'",
        sudo: true).error!
    remote_path = user_remote_path
  end

  Remotus.logger.debug { "Downloading file from #{@host}:#{remote_path}" }

  result = nil

  with_retries("Download #{remote_path} to #{local_path}", options[:retries] || DEFAULT_RETRIES) do
    result = connection.scp.download!(remote_path, local_path, options)
  end

  # Return the file content if that is desired
  local_path.nil? ? result : local_path
ensure
  # Sudo cleanup
  if options[:sudo]
    Remotus.logger.debug { "Sudo enabled, removing temporary file from #{@host}:#{user_remote_path}" }
    run("/bin/rm -f '#{user_remote_path}'", sudo: true).error!
  end
end

#file_exist?(remote_path, **options) ⇒ Boolean

Checks if a remote file or directory exists

Parameters:

  • remote_path (String)

    remote path to the file or directory

  • options (Hash)

    command options

Options Hash (**options):

  • :sudo (Boolean)

    whether to run the check with sudo (defaults to false)

  • :pty (Boolean)

    whether to allocate a terminal (defaults to false)

Returns:

  • (Boolean)

    true if the file or directory exists, false otherwise



429
430
431
432
# File 'lib/remotus/ssh_connection.rb', line 429

def file_exist?(remote_path, **options)
  Remotus.logger.debug { "Checking if file #{remote_path} exists on #{@host}" }
  run("test -f '#{remote_path}' || test -d '#{remote_path}'", **options).success?
end

#port_open?Boolean

Whether the remote host's SSH port is available

Returns:

  • (Boolean)

    true if available, false otherwise



187
188
189
# File 'lib/remotus/ssh_connection.rb', line 187

def port_open?
  Remotus.port_open?(remote_host, @port)
end

#run(command, *args, **options) ⇒ Remotus::Result

Runs a command on the host

Parameters:

  • command (String)

    command to run

  • args (Array)

    command arguments

  • options (Hash)

    command options

Options Hash (**options):

  • :sudo (Boolean)

    whether to run the command with sudo (defaults to false)

  • :pty (Boolean)

    whether to allocate a terminal (defaults to false)

  • :retries (Integer)

    number of times to retry a closed connection (defaults to 2)

  • :input (String)

    stdin input to provide to the command

  • :accepted_exit_codes (Array<Integer>)

    array of acceptable exit codes (defaults to [0]) only used if :on_error or :on_success are set

  • :on_complete (Proc)

    callback invoked when the command is finished (whether successful or unsuccessful)

  • :on_error (Proc)

    callback invoked when the command is unsuccessful

  • :on_output (Proc)

    callback invoked when any data is received

  • :on_stderr (Proc)

    callback invoked when stderr data is received

  • :on_stdout (Proc)

    callback invoked when stdout data is received

  • :on_success (Proc)

    callback invoked when the command is successful

Returns:

  • (Remotus::Result)

    result describing the stdout, stderr, and exit status of the command



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
# File 'lib/remotus/ssh_connection.rb', line 212

def run(command, *args, **options)
  command = "#{command}#{args.empty? ? "" : " "}#{args.join(" ")}"
  input = options[:input] || +""
  stdout = +""
  stderr = +""
  output = +""
  exit_code = nil
  retries ||= options[:retries] || DEFAULT_RETRIES
  accepted_exit_codes = options[:accepted_exit_codes] || [0]

  ssh_command = command

  # Refer to the command by object_id throughout the log to avoid logging sensitive data
  Remotus.logger.debug { "Preparing to run command #{command.object_id} on #{@host}" }

  with_retries(command, retries) do
    # Handle sudo
    if options[:sudo]
      Remotus.logger.debug { "Sudo is enabled for command #{command.object_id}" }
      ssh_command = "sudo -p '' -S sh -c '#{command.gsub("'", "'\"'\"'")}'"
      input = "#{Remotus::Auth.credential(self).password}\n#{input}"

      # If password was nil, raise an exception
      raise Remotus::MissingSudoPassword, "#{host} credential does not have a password specified" if input.start_with?("\n")
    end

    # Allocate a terminal if specified
    pty = options[:pty] || false
    skip_first_output = pty && options[:sudo]

    # Open an SSH channel to the host
    channel_handle = connection.open_channel do |channel|
      # Execute the command
      if pty
        Remotus.logger.debug { "Requesting pty for command #{command.object_id}" }
        channel.request_pty do |ch, success|
          raise Remotus::PtyError, "could not obtain pty" unless success

          ch.exec(ssh_command)
        end
      else
        Remotus.logger.debug { "Executing command #{command.object_id}" }
        channel.exec(ssh_command)
      end

      # Provide input
      unless input.empty?
        Remotus.logger.debug { "Sending input for command #{command.object_id}" }
        channel.send_data input
        channel.eof!
      end

      # Process stdout
      channel.on_data do |ch, data|
        # Skip the first iteration if sudo and pty is enabled to avoid outputting the sudo password
        if skip_first_output
          skip_first_output = false
          next
        end
        stdout << data
        output << data
        options[:on_stdout].call(ch, data) if options[:on_stdout].respond_to?(:call)
        options[:on_output].call(ch, data) if options[:on_output].respond_to?(:call)
      end

      # Process stderr
      channel.on_extended_data do |ch, _, data|
        stderr << data
        output << data
        options[:on_stderr].call(ch, data) if options[:on_stderr].respond_to?(:call)
        options[:on_output].call(ch, data) if options[:on_output].respond_to?(:call)
      end

      # Process exit status/code
      channel.on_request("exit-status") do |_, data|
        exit_code = data.read_long
      end
    end

    # Block until the command has completed execution
    channel_handle.wait

    Remotus.logger.debug { "Generating result for command #{command.object_id}" }
    result = Remotus::Result.new(command, stdout, stderr, output, exit_code)

    # If we are using sudo and experience an authentication failure, raise an exception
    if options[:sudo] && result.error? && !result.stderr.empty? && result.stderr.match?(/^sudo: \d+ incorrect password attempts?$/)
      raise Remotus::AuthenticationError, "Could not authenticate to sudo as #{Remotus::Auth.credential(self).user}"
    end

    # Perform success, error, and completion callbacks
    options[:on_success].call(result) if options[:on_success].respond_to?(:call) && result.success?(accepted_exit_codes)
    options[:on_error].call(result) if options[:on_error].respond_to?(:call) && result.error?(accepted_exit_codes)
    options[:on_complete].call(result) if options[:on_complete].respond_to?(:call)

    result
  end
end

#run_script(local_path, remote_path, *args, **options) ⇒ Remotus::Result

Uploads a script and runs it on the host

Parameters:

  • local_path (String)

    local path of the script (source)

  • remote_path (String)

    remote path for the script (destination)

  • args (Array)

    script arguments

  • options (Hash)

    command options

Options Hash (**options):

  • :sudo (Boolean)

    whether to run the script with sudo (defaults to false)

  • :pty (Boolean)

    whether to allocate a terminal (defaults to false)

  • :retries (Integer)

    number of times to retry a closed connection (defaults to 2)

  • :input (String)

    stdin input to provide to the command

  • :accepted_exit_codes (Array<Integer>)

    array of acceptable exit codes (defaults to [0]) only used if :on_error or :on_success are set

  • :on_complete (Proc)

    callback invoked when the command is finished (whether successful or unsuccessful)

  • :on_error (Proc)

    callback invoked when the command is unsuccessful

  • :on_output (Proc)

    callback invoked when any data is received

  • :on_stderr (Proc)

    callback invoked when stderr data is received

  • :on_stdout (Proc)

    callback invoked when stdout data is received

  • :on_success (Proc)

    callback invoked when the command is successful

Returns:

  • (Remotus::Result)

    result describing the stdout, stderr, and exit status of the command



333
334
335
336
337
338
# File 'lib/remotus/ssh_connection.rb', line 333

def run_script(local_path, remote_path, *args, **options)
  upload(local_path, remote_path, **options)
  Remotus.logger.debug { "Running script #{remote_path} on #{@host}" }
  run("chmod +x #{remote_path}", **options)
  run(remote_path, *args, **options)
end

#typeSymbol

Connection type

Returns:

  • (Symbol)

    returns :ssh



102
103
104
# File 'lib/remotus/ssh_connection.rb', line 102

def type
  :ssh
end

#upload(local_path, remote_path, options = {}) ⇒ String

Uploads a file from the local host to the remote host

Parameters:

  • local_path (String)

    local path to upload the file from (source)

  • remote_path (String)

    remote path to upload the file to (destination)

  • options (Hash) (defaults to: {})

    upload options

Options Hash (options):

  • :sudo (Boolean)

    whether to run the upload with sudo (defaults to false)

  • :owner (String)

    file owner (“oracle”)

  • :group (String)

    file group (“dba”)

  • :mode (String)

    file mode (“0640”)

  • :retries (Integer)

    number of times to retry a closed connection (defaults to 2)

Returns:



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/remotus/ssh_connection.rb', line 354

def upload(local_path, remote_path, options = {})
  Remotus.logger.debug { "Uploading file #{local_path} to #{@host}:#{remote_path}" }

  if options[:sudo]
    sudo_upload(local_path, remote_path, options)
  else
    permission_cmd = permission_cmds(remote_path, options[:owner], options[:group], options[:mode])

    with_retries("Upload #{local_path} to #{remote_path}", options[:retries] || DEFAULT_RETRIES) do
      connection.scp.upload!(local_path, remote_path, options)
    end

    run(permission_cmd).error! unless permission_cmd.empty?
  end

  remote_path
end