lib/kitchen/transport/winrm.rb



#
# Author:: Salim Afiune (<salim@afiunemaya.com.mx>)
# Author:: Matt Wrock (<matt@mattwrock.com>)
# Author:: Fletcher Nichol (<fnichol@nichol.ca>)
#
# Copyright (C) 2014, Salim Afiune
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require "rbconfig" unless defined?(RbConfig)
require "uri" unless defined?(URI)
require_relative "../../kitchen"
require_relative "../util"
require "winrm" unless defined?(WinRM::Connection)
require "winrm/exceptions" unless defined?(WinRM::WinRMHTTPTransportError)

module Kitchen
  module Transport
    # Wrapped exception for any internally raised WinRM-related errors.
    #
    # @author Fletcher Nichol <fnichol@nichol.ca>
    class WinrmFailed < TransportFailed; end

    # A Transport which uses WinRM to execute commands and transfer files.
    #
    # @author Matt Wrock <matt@mattwrock.com>
    # @author Salim Afiune <salim@afiunemaya.com.mx>
    # @author Fletcher Nichol <fnichol@nichol.ca>
    class Winrm < Kitchen::Transport::Base
      kitchen_transport_api_version 1

      plugin_version Kitchen::VERSION

      default_config :username, "administrator"
      default_config :password, nil
      default_config :elevated, false
      default_config :rdp_port, 3389
      default_config :connection_retries, 5
      default_config :connection_retry_sleep, 1
      default_config :operation_timeout, 60
      default_config :receive_timeout, 70
      default_config :max_wait_until_ready, 600
      default_config :winrm_transport, :negotiate
      default_config :scheme do |transport|
        transport[:winrm_transport] == :ssl ? "https" : "http"
      end
      default_config :port do |transport|
        transport[:winrm_transport] == :ssl ? 5986 : 5985
      end

      def finalize_config!(instance)
        super

        config[:winrm_transport] = config[:winrm_transport].to_sym

        self
      end

      # (see Base#connection)
      def connection(state, &block)
        options = connection_options(config.to_hash.merge(state))

        if @connection && @connection_options == options
          reuse_connection(&block)
        else
          create_new_connection(options, &block)
        end
      end

      # A Connection instance can be generated and re-generated, given new
      # connection details such as connection port, hostname, credentials, etc.
      # This object is responsible for carrying out the actions on the remote
      # host such as executing commands, transferring files, etc.
      #
      # @author Fletcher Nichol <fnichol@nichol.ca>
      class Connection < Kitchen::Transport::Base::Connection
        # (see Base::Connection#initialize)
        def initialize(config = {})
          super(config)
          @unelevated_session = nil
          @elevated_session = nil
        end

        # (see Base::Connection#close)
        def close
          @unelevated_session.close if @unelevated_session
          @elevated_session.close if @elevated_session
        ensure
          @unelevated_session = nil
          @elevated_session = nil
          @file_transporter = nil
        end

        # (see Base::Connection#execute)
        def execute(command)
          return if command.nil?

          string_to_mask = "[WinRM] #{self} (#{command})"
          masked_string = Util.mask_values(string_to_mask, %w{password ssh_http_proxy_password})
          logger.debug(masked_string)

          exit_code, stderr = execute_with_exit_code(command)

          if logger.debug? && exit_code == 0
            log_stderr_on_warn(stderr)
          elsif exit_code != 0
            log_stderr_on_warn(stderr)
            raise Transport::WinrmFailed.new(
              "WinRM exited (#{exit_code}) for command: [#{command}]",
              exit_code
            )
          end
        end

        def retry?(current_try, max_retries, retryable_exit_codes, exception)
          # Avoid duplicating Kitchen::Transport::Base#retry?
          result = super
          return result if result == true

          case exception
          when WinRM::WinRMHTTPTransportError
            return current_try <= max_retries &&
                [400, 500].include?(exception.status_code)
          when WinRM::WinRMWSManFault
            return current_try <= max_retries
          end

          false
        end

        # (see Base::Connection#login_command)
        def login_command
          case RbConfig::CONFIG["host_os"]
          when /darwin/
            login_command_for_mac
          when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
            login_command_for_windows
          when /linux/
            login_command_for_linux
          else
            raise ActionFailed, "Remote login not supported in #{self.class} " \
              "from host OS '#{RbConfig::CONFIG["host_os"]}'."
          end
        end

        # (see Base::Connection#upload)
        def upload(locals, remote)
          file_transporter.upload(locals, remote)
        end

        # (see Base::Connection#download)
        def download(remotes, local)
          # ensure the parent dir of the local target exists
          FileUtils.mkdir_p(File.dirname(local))

          Array(remotes).each do |remote|
            file_manager.download(remote, local)
          end
        end

        # @return [Winrm::FileManager] a file transporter
        # @api private
        def file_manager
          @file_manager ||= WinRM::FS::FileManager.new(connection)
        end

        # (see Base::Connection#wait_until_ready)
        def wait_until_ready
          delay = 3
          unelevated_session(
            retry_limit: max_wait_until_ready / delay,
            retry_delay: delay
          )
          execute(PING_COMMAND.dup)
        rescue *RESCUE_EXCEPTIONS_ON_ESTABLISH => e
          retries ||= connection_retries.to_i
          raise e if (retries -= 1) < 0

          logger.debug("[WinRM] PING_COMMAND failed. Retrying...")
          logger.debug("#{e.class}::#{e.message}")
          sleep(connection_retry_sleep.to_i)
          retry
        end

        private

        PING_COMMAND = "Write-Host '[WinRM] Established\n'".freeze

        RESCUE_EXCEPTIONS_ON_ESTABLISH = [
          Errno::EACCES, Errno::EALREADY, Errno::EADDRINUSE, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
          Errno::ECONNRESET, Errno::ENETUNREACH, Errno::EHOSTUNREACH, Errno::EPIPE,
          OpenSSL::SSL::SSLError, WinRM::WinRMHTTPTransportError
        ].freeze

        # @return [Integer] how many times to retry when failing to execute
        #   a command or transfer files
        # @api private
        attr_reader :connection_retries

        # @return [Float] how many seconds to wait before attempting a retry
        #   when failing to execute a command or transfer files
        # @api private
        attr_reader :connection_retry_sleep

        # @return [String] display name for the associated instance
        # @api private
        attr_reader :instance_name

        # @return [String] local path to the root of the project
        # @api private
        attr_reader :kitchen_root

        # @return [Integer] how many times to retry when invoking
        #   `#wait_until_ready` before failing
        # @api private
        attr_reader :max_wait_until_ready

        # @return [Integer] the TCP port number to use when connection to the
        #   remote WinRM host
        # @api private
        attr_reader :rdp_port

        # @return [Boolean] whether to use winrm-elevated for running commands
        # @api private
        attr_reader :elevated

        # Writes an RDP document to the local file system.
        #
        # @param opts [Hash] file options
        # @option opts [true,false] :mac whether or not the document is for a
        #   Mac system
        # @api private
        def create_rdp_doc(opts = {})
          content = Util.outdent!(<<-RDP)
            full address:s:#{URI.parse(options[:endpoint]).host}:#{rdp_port}
            prompt for credentials:i:1
            username:s:#{options[:user]}
          RDP
          content.prepend("drivestoredirect:s:*\n") if opts[:mac]

          File.open(rdp_doc_path, "wb") { |f| f.write(content) }

          if logger.debug?
            debug("Creating RDP document for #{instance_name} (#{rdp_doc_path})")
            debug("------------")
            File.read(rdp_doc_path).each_line { |l| debug(l.chomp.to_s) }
            debug("------------")
          end
        end

        # Execute a PowerShell script over WinRM and return the command's
        # exit code and standard error.
        #
        # @param command [String] PowerShell script to execute
        # @return [[Integer,String]] an array containing the exit code of the
        #   script and the standard error stream
        # @api private
        def execute_with_exit_code(command)
          if elevated
            session = elevated_session
            command = "$env:temp='#{unelevated_temp_dir}';#{command}"
          else
            session = unelevated_session
          end

          begin
            response = session.run(command) do |stdout, _|
              logger << stdout if stdout
            end
            [response.exitcode, response.stderr]
          ensure
            close
          end
        end

        def unelevated_temp_dir
          @unelevated_temp_dir ||= unelevated_session.run("$env:temp").stdout.chomp
        end

        # @return [Winrm::FileTransporter] a file transporter
        # @api private
        def file_transporter
          @file_transporter ||= WinRM::FS::Core::FileTransporter.new(unelevated_session)
        end

        # (see Base#init_options)
        def init_options(options)
          super
          @instance_name = @options.delete(:instance_name)
          @kitchen_root = @options.delete(:kitchen_root)
          @rdp_port = @options.delete(:rdp_port)
          @connection_retries = @options.delete(:connection_retries)
          @connection_retry_sleep = @options.delete(:connection_retry_sleep)
          @operation_timeout = @options.delete(:operation_timeout)
          @receive_timeout = @options.delete(:receive_timeout)
          @max_wait_until_ready = @options.delete(:max_wait_until_ready)
          @elevated = @options.delete(:elevated)
        end

        # Logs formatted standard error output at the warning level.
        #
        # @param stderr [String] standard error output
        # @api private
        def log_stderr_on_warn(stderr)
          error_regexp = /<S S=\"Error\">/

          if error_regexp.match(stderr)
            stderr
              .split(error_regexp)[1..-2]
              .map! { |line| line.sub(%r{_x000D__x000A_</S>}, "").rstrip }
              .each { |line| logger.warn(line) }
          else
            stderr
              .split("\r\n")
              .each { |line| logger.warn(line) }
          end
        end

        # Builds a `LoginCommand` for use by Linux-based platforms.
        #
        # @return [LoginCommand] a login command
        # @api private
        def login_command_for_linux
          xfreerdp = Util.command_exists? "xfreerdp"
          unless xfreerdp
            raise WinrmFailed, "xfreerdp binary not found. Please install freerdp2-x11 on Debian-based systems or freerdp on Redhat-based systems."
          end

          args  = %W{/u:#{options[:user]}}
          args += %W{/p:#{options[:password]}} if options.key?(:password)
          args += %W{/v:#{URI.parse(options[:endpoint]).host}:#{rdp_port}}
          args += %w{/cert-tofu} # always accept certificate

          LoginCommand.new(xfreerdp, args)
        end

        # Builds a `LoginCommand` for use by Mac-based platforms.
        #
        # @return [LoginCommand] a login command
        # @api private
        def login_command_for_mac
          create_rdp_doc(mac: true)

          LoginCommand.new("open", rdp_doc_path)
        end

        # Builds a `LoginCommand` for use by Windows-based platforms.
        #
        # @return [LoginCommand] a login command
        # @api private
        def login_command_for_windows
          create_rdp_doc

          LoginCommand.new("mstsc", rdp_doc_path)
        end

        # @return [String] path to the local RDP document
        # @api private
        def rdp_doc_path
          File.join(kitchen_root, ".kitchen", "#{instance_name}.rdp")
        end

        # Establishes a remote shell session, or establishes one when invoked
        # the first time.
        #
        # @param retry_options [Hash] retry options for the initial connection
        # @return [Winrm::Shells::Powershell] the command shell session
        # @api private
        def unelevated_session(retry_options = {})
          @unelevated_session ||= connection(retry_options).shell(:powershell)
        end

        # Creates an elevated session for running commands via a scheduled task
        #
        # @return [Winrm::Shells::Elevated] the elevated shell
        # @api private
        def elevated_session(retry_options = {})
          @elevated_session ||= connection(retry_options).shell(:elevated).tap do |shell|
            shell.username = options[:elevated_username]
            shell.password = options[:elevated_password]
          end
        end

        # Creates a winrm Connection instance
        #
        # @param retry_options [Hash] retry options for the initial connection
        # @return [Winrm::Connection] the winrm connection
        # @api private
        def connection(retry_options = {})
          @connection ||= begin
            opts = {
              retry_limit: connection_retries.to_i,
              retry_delay: connection_retry_sleep.to_i,
            }.merge(retry_options)

            ::WinRM::Connection.new(options.merge(opts)).tap do |conn|
              conn.logger = logger
            end
          end
        end

        # String representation of object, reporting its connection details and
        # configuration.
        #
        # @api private
        def to_s
          "<#{options.inspect}>"
        end
      end

      private

      WINRM_SPEC_VERSION = ["~> 2.0"].freeze
      WINRM_FS_SPEC_VERSION = ["~> 1.0"].freeze
      WINRM_ELEVATED_SPEC_VERSION = ["~> 1.0"].freeze

      # Builds the hash of options needed by the Connection object on
      # construction.
      #
      # @param data [Hash] merged configuration and mutable state data
      # @return [Hash] hash of connection options
      # @api private
      def connection_options(data)
        endpoint = URI::Generic.build(
          scheme: data.fetch(:scheme),
          host: data.fetch(:hostname),
          port: data.fetch(:port),
          path: "/wsman"
        ).to_s

        elevated_password = data[:password]
        elevated_password = data[:elevated_password] if data.key?(:elevated_password)

        opts = {
          instance_name: instance.name,
          kitchen_root: data[:kitchen_root],
          logger:,
          endpoint:,
          user: data[:username],
          password: data[:password],
          rdp_port: data[:rdp_port],
          connection_retries: data[:connection_retries],
          connection_retry_sleep: data[:connection_retry_sleep],
          operation_timeout: data[:operation_timeout],
          receive_timeout: data[:receive_timeout],
          max_wait_until_ready: data[:max_wait_until_ready],
          transport: data[:winrm_transport],
          elevated: data[:elevated],
          elevated_username: data[:elevated_username] || data[:username],
          elevated_password:,
        }
        opts.merge!(additional_transport_args(data, opts[:transport]))
        if opts[:transport].to_sym == :ssl && opts.key?(:client_cert) && opts.key?(:client_key)
          opts.delete(:user)
          opts.delete(:password)
        end
        opts
      end

      def additional_transport_args(data, transport_type)
        opts = {
          disable_sspi: false,
          basic_auth_only: false,
        }

        case transport_type.to_sym
        when :ssl
          if data.key?(:client_cert) && data.key?(:client_key)
            opts[:client_cert] = data[:client_cert]
            opts[:client_key] = data[:client_key]
          end
          opts[:no_ssl_peer_verification] = data.key?(:no_ssl_peer_verification) ? data[:no_ssl_peer_verification] : true
          opts
        when :negotiate
          opts[:no_ssl_peer_verification] = true
          opts
        when :plaintext
          {
            disable_sspi: true,
            basic_auth_only: true,
          }
        else
          {}
        end
      end

      # Creates a new WinRM Connection instance and save it for potential
      # future reuse.
      #
      # @param options [Hash] connection options
      # @return [Ssh::Connection] a WinRM Connection instance
      # @api private
      def create_new_connection(options, &block)
        if @connection
          string_to_mask = "[WinRM] shutting previous connection #{@connection}"
          masked_string = Util.mask_values(string_to_mask, %w{password ssh_http_proxy_password})
          logger.debug(masked_string)
          @connection.close
        end

        @connection_options = options
        @connection = Kitchen::Transport::Winrm::Connection.new(options, &block)
      end

      # (see Base#load_needed_dependencies!)
      def load_needed_dependencies!
        super
        load_with_rescue!("winrm", WINRM_SPEC_VERSION.dup)
        load_with_rescue!("winrm-fs", WINRM_FS_SPEC_VERSION.dup)
        load_with_rescue!("winrm-elevated", WINRM_ELEVATED_SPEC_VERSION.dup) if config[:elevated]
      end

      def load_with_rescue!(gem_name, spec_version)
        logger.debug("#{gem_name} requested," \
          " loading #{gem_name} gem (#{spec_version})")
        attempt_load = false
        gem gem_name, spec_version
        silence_warnings { attempt_load = require gem_name }
        if attempt_load
          logger.debug("#{gem_name} is loaded.")
        else
          logger.debug("#{gem_name} was already loaded.")
        end
      rescue LoadError => e
        message = fail_to_load_gem_message(gem_name,
          spec_version)
        logger.fatal(message)
        raise UserError,
          "Could not load or activate #{gem_name}. (#{e.message})"
      end

      def fail_to_load_gem_message(name, version = nil)
        version_cmd = "--version '#{version}'" if version
        version_file = "', '#{version}"

        "The `#{name}` gem is missing and must" \
          " be installed or cannot be properly activated. Run" \
          " `gem install #{name} #{version_cmd}`" \
          " or add the following to your Gemfile if you are using Bundler:" \
          " `gem '#{name} #{version_file}'`."
      end

      def host_os_windows?
        case RbConfig::CONFIG["host_os"]
        when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
          true
        else
          false
        end
      end

      # Return the last saved WinRM connection instance.
      #
      # @return [Winrm::Connection] a WinRM Connection instance
      # @api private
      def reuse_connection
        string_to_mask = "[WinRM] reusing existing connection #{@connection}"
        masked_string = Util.mask_values(string_to_mask, %w{password ssh_http_proxy_password})
        logger.debug(masked_string)
        yield @connection if block_given?
        @connection
      end

      def silence_warnings
        old_verbose = $VERBOSE
        $VERBOSE = nil
        yield
      ensure
        $VERBOSE = old_verbose
      end
    end
  end
end