require 'chef/provisioning/transport'
require 'chef/log'
require 'uri'
require 'socket'
require 'timeout'
require 'net/ssh'
require 'net/scp'
require 'net/ssh/gateway'

class Chef
module Provisioning
  class Transport
    class SSH < Chef::Provisioning::Transport
      #
      # 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)

      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

      attr_reader :host
      attr_reader :username
      attr_reader :ssh_options
      attr_reader :options
      attr_reader :config

      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

      # TODO why does #read_file download it to the target host?
      def read_file(path)
        Chef::Log.debug("Reading file #{path} from #{username}@#{host}")
        result = StringIO.new
        download(path, result)
        result.string
      end

      def download_file(path, local_path)
        Chef::Log.debug("Downloading file #{path} from #{username}@#{host} to local #{local_path}")
        download(path, local_path)
      end

      def remote_tempfile(path)
        File.join(scp_temp_dir, "#{File.basename(path)}.#{Random.rand(2**32)}")
      end

      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

      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

      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

      def disconnect
        if @session
          begin
            Chef::Log.info("Closing SSH session on #{username}@#{host}")
            @session.close
          rescue
          ensure
            @session = nil
          end
        end
      end

      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

      protected

      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

      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

      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

      class SSHResult
        def initialize(command, options, stdout, stderr, exitstatus)
          @command = command
          @options = options
          @stdout = stdout
          @stderr = stderr
          @exitstatus = exitstatus
        end

        attr_reader :command
        attr_reader :options
        attr_reader :stdout
        attr_reader :stderr
        attr_reader :exitstatus

        def error!
          if exitstatus != 0
            # TODO stdout/stderr is already printed at info/debug level.  Let's not print it twice, it's a lot.
            msg = "Error: command '#{command}' exited with code #{exitstatus}.\n"
            raise msg
          end
        end
      end

      class InitialConnectTimeout < Timeout::Error
        def initialize(original_error)
          super(original_error.message)
          @original_error = original_error
        end

        attr_reader :original_error
      end

      private

      def scp_temp_dir
        @scp_temp_dir ||= options.fetch(:scp_temp_dir, '/tmp')
      end

      def gateway?
        options.key?(:ssh_gateway) and ! options[:ssh_gateway].nil?
      end

      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

      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

      # Forwards a port over the connection, and returns the
      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
    end
  end
end
end
