class Chef::Provisioning::Transport::SSH

Attributes

config[R]
host[R]
options[R]
ssh_options[R]
username[R]

Public Class Methods

new(host, username, init_ssh_options, options, global_config) click to toggle source

Create a new SSH transport.

Arguments

  • host: the host to connect to, e.g. '145.14.51.45'

  • username: the username to connect with

  • ssh_options: a list of options to Net::SSH.start

  • options: a hash of options for the transport itself, including:

    • :prefix: a prefix to send before each command (e.g. “sudo ”)

    • :ssh_pty_enable: set to false to disable pty (some instances don't support this, most do)

    • :ssh_gateway: the gateway to use, e.g. “jkeiser@145.14.51.45:222”. nil (the default) means no gateway. If the username is omitted, then the default username is used instead (i.e. the user running chef, or the username configured in .ssh/config).

    • :scp_temp_dir: a directory to use as the temporary location for files that are copied to the host via SCP. Only used if :prefix is set. Default is '/tmp' if unspecified.

  • global_config: an options hash that looks suspiciously similar to Chef::Config, containing at least the key :log_level.

The options are used in

Net::SSH.start(host, username, ssh_options)
# File lib/chef/provisioning/transport/ssh.rb, line 39
def initialize(host, username, init_ssh_options, options, global_config)
  @host = host
  @username = username
  @ssh_options = init_ssh_options.clone
  @options = options
  @config = global_config
  @remote_forwards = ssh_options.delete(:remote_forwards) { Array.new }
  @never_forward_localhost = ssh_options.delete(:never_forward_localhost)
end

Public Instance Methods

available?() click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 213
def available?
  timeout = ssh_options[:timeout] || 10
  execute('pwd', :timeout => timeout)
  true
rescue Timeout::Error, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EHOSTDOWN, Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::ECONNRESET, Net::SSH::Disconnect, Net::SSH::ConnectionTimeout
  Chef::Log.debug("#{username}@#{host} unavailable: network connection failed or broke: #{$!.inspect}")
  disconnect
  false
rescue Net::SSH::AuthenticationFailed, Net::SSH::HostKeyMismatch
  Chef::Log.debug("#{username}@#{host} unavailable: SSH authentication error: #{$!.inspect} ")
  disconnect
  false
end
disconnect() click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 201
def disconnect
  if @session
    begin
      Chef::Log.info("Closing SSH session on #{username}@#{host}")
      @session.close
    rescue
    ensure
      @session = nil
    end
  end
end
download_file(path, local_path) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 133
def download_file(path, local_path)
  Chef::Log.debug("Downloading file #{path} from #{username}@#{host} to local #{local_path}")
  download(path, local_path)
end
execute(command, execute_options = {}) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 55
def execute(command, execute_options = {})
  Chef::Log.info("#{self.object_id} Executing #{options[:prefix]}#{command} on #{username}@#{host}")
  stdout = ''
  stderr = ''
  exitstatus = nil
  session # grab session outside timeout, it has its own timeout

  with_execute_timeout(execute_options) do
    @remote_forwards.each do |forward_info|
        # -R flag to openssh client allows optional :remote_host and
        # requires the other values so let's do that too.
        remote_host = forward_info.fetch(:remote_host, 'localhost')
        remote_port = forward_info.fetch(:remote_port)
        local_host = forward_info.fetch(:local_host)
        local_port = forward_info.fetch(:local_port)

        actual_port, actual_host = forward_port(local_port, local_host, remote_port, remote_host)
        Chef::Log.info("#{host} forwarded remote #{actual_host}:#{actual_port} to local #{local_host}:#{local_port}")
    end

    channel = session.open_channel do |channel|
      # Enable PTY unless otherwise specified, some instances require this
      unless options[:ssh_pty_enable] == false
        channel.request_pty do |chan, success|
           raise "could not get pty" if !success && options[:ssh_pty_enable]
        end
      end

      channel.exec("#{options[:prefix]}#{command}") do |ch, success|
        raise "could not execute command: #{command.inspect}" unless success

        channel.on_data do |ch2, data|
          stdout << data
          stream_chunk(execute_options, data, nil)
        end

        channel.on_extended_data do |ch2, type, data|
          stderr << data
          stream_chunk(execute_options, nil, data)
        end

        channel.on_request "exit-status" do |ch, data|
          exitstatus = data.read_long
        end
      end
    end

    channel.wait

    @remote_forwards.each do |forward_info|
        # -R flag to openssh client allows optional :remote_host and
        # requires the other values so let's do that too.
        remote_host = forward_info.fetch(:remote_host, 'localhost')
        remote_port = forward_info.fetch(:remote_port)
        local_host = forward_info.fetch(:local_host)
        local_port = forward_info.fetch(:local_port)

        session.forward.cancel_remote(remote_port, remote_host)
        session.loop { session.forward.active_remotes.include?([remote_port, remote_host]) }

        Chef::Log.info("#{host} canceled remote forward #{remote_host}:#{remote_port}")
    end
  end

  Chef::Log.info("Completed #{command} on #{username}@#{host}: exit status #{exitstatus}")
  Chef::Log.debug("Stdout was:\n#{stdout}") if stdout != '' && !options[:stream] && !options[:stream_stdout] && config[:log_level] != :debug
  Chef::Log.info("Stderr was:\n#{stderr}") if stderr != '' && !options[:stream] && !options[:stream_stderr] && config[:log_level] != :debug
  SSHResult.new(command, execute_options, stdout, stderr, exitstatus)
end
make_url_available_to_remote(local_url) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 175
def make_url_available_to_remote(local_url)
  uri = URI(local_url)
  if @never_forward_localhost
    return uri.to_s
  elsif uri.scheme == 'chefzero' && !ChefZero::SocketlessServerMap.server_on_port(uri.port).server
    # There is no .server for a socketless, for a socket-d server it would
    # be a WEBrick::HTTPServer object.
    raise 'Cannot forward a socketless Chef Zero server, see https://docs.chef.io/deprecations_local_listen.html for more information'
  elsif is_local_machine(uri.host)
    port, host = forward_port(uri.port, uri.host, uri.port, 'localhost')
    if !port
      # Try harder if the port is already taken
      port, host = forward_port(uri.port, uri.host, 0, 'localhost')
      if !port
        raise "Error forwarding port: could not forward #{uri.port} or 0"
      end
    end
    uri.host = host
    uri.port = port
    Chef::Log.info("Port forwarded: local URL #{local_url} is available to #{self.host} as #{uri.to_s} for the duration of this SSH connection.")
  else
    Chef::Log.info("#{host} not forwarding non-local #{local_url}")
  end
  uri.to_s
end
read_file(path) click to toggle source

TODO why does read_file download it to the target host?

# File lib/chef/provisioning/transport/ssh.rb, line 126
def read_file(path)
  Chef::Log.debug("Reading file #{path} from #{username}@#{host}")
  result = StringIO.new
  download(path, result)
  result.string
end
remote_tempfile(path) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 138
def remote_tempfile(path)
  File.join(scp_temp_dir, "#{File.basename(path)}.#{Random.rand(2**32)}")
end
upload_file(local_path, path) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 156
def upload_file(local_path, path)
  execute("mkdir -p #{File.dirname(path)}").error!
  if options[:prefix]
    # Make a tempfile on the other side, upload to that, and sudo mv / chown / etc.
    tempfile = remote_tempfile(path)
    Chef::Log.debug("Uploading #{local_path} to #{tempfile} on #{username}@#{host}")
    Net::SCP.new(session).upload!(local_path, tempfile)
    begin
      execute("mv #{tempfile} #{path}").error!
    rescue
      # Clean up if we were unable to move
      execute("rm #{tempfile}").error!
    end
  else
    Chef::Log.debug("Uploading #{local_path} to #{path} on #{username}@#{host}")
    Net::SCP.new(session).upload!(local_path, path)
  end
end
write_file(path, content) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 142
def write_file(path, content)
  execute("mkdir -p #{File.dirname(path)}").error!
  if options[:prefix]
    # Make a tempfile on the other side, upload to that, and sudo mv / chown / etc.
    tempfile = remote_tempfile(path)
    Chef::Log.debug("Writing #{content.length} bytes to #{tempfile} on #{username}@#{host}")
    Net::SCP.new(session).upload!(StringIO.new(content), tempfile)
    execute("mv #{tempfile} #{path}").error!
  else
    Chef::Log.debug("Writing #{content.length} bytes to #{path} on #{username}@#{host}")
    Net::SCP.new(session).upload!(StringIO.new(content), path)
  end
end

Protected Instance Methods

do_download(path, local_path) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 271
def do_download(path, local_path)
  channel = Net::SCP.new(session).download(path, local_path)
  begin
    channel.wait
    Chef::Log.debug "SCP completed for: #{path} to #{local_path}"
  rescue Net::SCP::Error => e
    Chef::Log.error "Error with SCP: #{e}"
    # TODO we need a way to distinguish between "directory or file does not exist" and "SCP did not finish successfully"
    nil
  ensure
    # ensure the channel is closed
    channel.close
    channel.wait
  end

  nil
end
download(path, local_path) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 246
def download(path, local_path)
  if options[:prefix]
    # Make a tempfile on the other side, upload to that, and sudo mv / chown / etc.
    tempfile = remote_tempfile(path)
    Chef::Log.debug("Downloading #{path} from #{tempfile} to #{local_path} on #{username}@#{host}")
    begin
      execute("cp #{path} #{tempfile}").error!
      execute("chown #{username} #{tempfile}").error!
      do_download tempfile, local_path
    rescue => e
        Chef::Log.error "Unable to download #{path} to #{tempfile} on #{username}@#{host} -- #{e}"
        nil
    ensure
      # Clean up afterwards
      begin
        execute("rm #{tempfile}").error!
      rescue => e
        Chef::Log.warn "Unable to clean up #{tempfile} on #{username}@#{host} -- #{e}"
      end
    end
  else
    do_download path, local_path
  end
end
session() click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 229
def session
  @session ||= begin
    # Small initial connection timeout (10s) to help us fail faster when server is just dead
    ssh_start_opts = { timeout:10 }.merge(ssh_options)
    Chef::Log.debug("Opening SSH connection to #{username}@#{host} with options #{ssh_start_opts.dup.tap {
                        |ssh| ssh.delete(:key_data) }.inspect}")
    begin
      if gateway? then gateway.ssh(host, username, ssh_start_opts)
      else Net::SSH.start(host, username, ssh_start_opts)
      end
    rescue Timeout::Error, Net::SSH::ConnectionTimeout
      Chef::Log.debug("Timed out connecting to SSH: #{$!}")
      raise InitialConnectTimeout.new($!)
    end
  end
end

Private Instance Methods

forward_port(local_port, local_host, remote_port, remote_host) click to toggle source

Forwards a port over the connection, and returns the

# File lib/chef/provisioning/transport/ssh.rb, line 363
def forward_port(local_port, local_host, remote_port, remote_host)
  # This bit is from the documentation.
  if session.forward.respond_to?(:active_remote_destinations)
    # active_remote_destinations tells us exactly what remotes the current
    # ssh session is *actually* tracking.  If multiple people share this
    # session and set up their own remotes, this will prevent us from
    # overwriting them.

    actual_remote_port, actual_remote_host = session.forward.active_remote_destinations[[local_port, local_host]]
    if !actual_remote_port
      Chef::Log.info("Forwarding local server #{local_host}:#{local_port} to #{username}@#{self.host}")

      session.forward.remote(local_port, local_host, remote_port, remote_host) do |new_remote_port, new_remote_host|
                                                  actual_remote_host = new_remote_host
        actual_remote_port = new_remote_port || :error
        :no_exception # I'll take care of it myself, thanks
      end
      # Kick SSH until we get a response
      session.loop { !actual_remote_port }
      if actual_remote_port == :error
        return nil
      end
    end
    [ actual_remote_port, actual_remote_host ]
  else
    # If active_remote_destinations isn't on net-ssh, we stash our own list
    # of ports *we* have forwarded on the connection, and hope that we are
    # right.
    # TODO let's remove this when net-ssh 2.9.2 is old enough, and
    # bump the required net-ssh version.

    @forwarded_ports ||= {}
    remote_port, remote_host = @forwarded_ports[[local_port, local_host]]
    if !remote_port
      Chef::Log.debug("Forwarding local server #{local_host}:#{local_port} to #{username}@#{self.host}")
      old_active_remotes = session.forward.active_remotes
      session.forward.remote(local_port, local_host, local_port)
      session.loop { !(session.forward.active_remotes.length > old_active_remotes.length) }
      remote_port, remote_host = (session.forward.active_remotes - old_active_remotes).first
      @forwarded_ports[[local_port, local_host]] = [ remote_port, remote_host ]
    end
    [ remote_port, remote_host ]
  end
end
gateway() click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 332
def gateway
  gw_user, gw_host = options[:ssh_gateway].split('@')
  # If we didn't have an '@' in the above, then the value is actually
  # the hostname, not the username.
  gw_host, gw_user = gw_user, gw_host if gw_host.nil?
  gw_host, gw_port = gw_host.split(':')

  ssh_start_opts = { timeout:10 }.merge(ssh_options)
  ssh_start_opts[:port] = gw_port || 22

  Chef::Log.debug("Opening SSH gateway to #{gw_user}@#{gw_host} with options #{ssh_start_opts.dup.tap {
                      |ssh| ssh.delete(:key_data) }.inspect}")
  begin
    Net::SSH::Gateway.new(gw_host, gw_user, ssh_start_opts)
  rescue Errno::ETIMEDOUT
    Chef::Log.debug("Timed out connecting to gateway: #{$!}")
    raise InitialConnectTimeout.new($!)
  end
end
gateway?() click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 328
def gateway?
  options.key?(:ssh_gateway) and ! options[:ssh_gateway].nil?
end
is_local_machine(host) click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 352
def is_local_machine(host)
  local_addrs = Socket.ip_address_list
  host_addrs = Addrinfo.getaddrinfo(host, nil)
  local_addrs.any? do |local_addr|
    host_addrs.any? do |host_addr|
      local_addr.ip_address == host_addr.ip_address
    end
  end
end
scp_temp_dir() click to toggle source
# File lib/chef/provisioning/transport/ssh.rb, line 324
def scp_temp_dir
  @scp_temp_dir ||= options.fetch(:scp_temp_dir, '/tmp')
end