# Copyright 2010-present Basho Technologies, Inc.
#
# 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
#
#     http://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 'socket'
require 'openssl'
require 'cert_validator'
require 'riak/client/beefcake/messages'
require 'riak/errors/connection_error'

module Riak
  class Client
    class BeefcakeProtobuffsBackend
      # A factory class for making sockets, whether secure or not
      # @api private
      class BeefcakeSocket
        include Client::BeefcakeMessageCodes
        # Only create class methods, don't initialize
        class << self
          def new(host, port, options = {})
            return start_tcp_socket(host, port, options) if options[:authentication].blank?
            return start_tls_socket(host, port, options)
          end

          private
          def start_tcp_socket(host, port, options = {})
            sock = if options[:connect_timeout] && RUBY_VERSION >= '2.0.0'
                     Socket.tcp(host, port, connect_timeout: options[:connect_timeout])
                   else
                     Socket.tcp(host, port)
                   end
            sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true)
            sock
          end

          def start_tls_socket(host, port, options)
            authentication = options[:authentication]
            raise Riak::UserConfigurationError.new if authentication[:username]

            tcp = start_tcp_socket(host, port, options)
            TlsInitiator.new(tcp, host, authentication).tls_socket
          end

          # Wrap up the logic to turn a TCP socket into a TLS socket.
          # Depends on Beefcake, which should be relatively safe.
          class TlsInitiator
            BC = ::Riak::Client::BeefcakeProtobuffsBackend
            include Util::Translation

            # Create a TLS Initiator
            #
            # @param tcp_socket [TCPSocket] the {TCPSocket} to start TLS on
            # @param authentication [Hash] a hash of authentication details
            def initialize(tcp_socket, host, authentication)
              @sock = @tcp = tcp_socket
              @host = host
              @auth = authentication
            end

            # Return the SSLSocket that has a TLS session running. (TLS is a
            # better and safer SSL).
            #
            # @return [OpenSSL::SSL::SSLSocket]
            def tls_socket
              configure_context
              start_tls
              validate_session
              send_authentication
              validate_connection
              return @tls
            end

            private
            def riak_cert
              @riak_cert ||= @tls.peer_cert
            end

            def ca_cert
              @ca_cert ||= @tls.peer_cert_chain[1]
            end

            # Set up an SSL context with appropriate defaults for Riak TLS
            def configure_context
              @context = OpenSSL::SSL::SSLContext.new

              # Replace insecure defaults
              @context.ssl_version = (@auth[:ssl_version] || default_ssl_version).to_sym
              @context.verify_mode = (@auth[:verify_mode] || OpenSSL::SSL::VERIFY_PEER).to_i

              cert_ify
              key_ify

              # Defer to defaults
              %w{ cert key client_ca ca_file ca_path timeout }.each do |k|
                @context.send(:"#{k}=", @auth[k.to_sym]) if @auth[k.to_sym]
              end
            end

            # Choose the most secure SSL version available
            def default_ssl_version
              available = OpenSSL::SSL::SSLContext::METHODS
              selected = %w{TLSv1_2_client TLSv1_1_client TLSv1.1 TLSv1_client TLS}.detect do |v|
                available.include? v.to_sym
              end

              raise TlsError::SslVersionConfigurationError.new unless selected

              return selected
            end

            # Convert cert and client_ca fields to X509 Certs
            def cert_ify
              %w{ cert client_ca }.each do |k|
                candidate = @auth[k.to_sym]
                next if candidate.nil?
                next if candidate.is_a? OpenSSL::X509::Certificate

                @auth[k.to_sym] = OpenSSL::X509::Certificate.new try_load candidate
              end
            end

            def key_ify
              candidate = @auth[:key]
              return if candidate.nil?
              return if candidate.is_a? OpenSSL::PKey::PKey

              candidate = try_load candidate

              pkey_class_names = OpenSSL::PKey.
                constants.
                reject{|s| s.to_s =~ /Error$/}

              pkey_classes = pkey_class_names.map{ |n| OpenSSL::PKey.const_get n }

              pkey_classes.each do |klass|
                begin
                  successfully_initialized = klass.new candidate
                  @auth[:key] = successfully_initialized
                  return
                rescue
                  next
                end
              end

              # Don't try and guess what the key is
              raise TlsError::UnknownKeyTypeError.new
            end

            # Figure out if the given string is the data itself or a path to the data
            def try_load(data_or_path)
              begin
                data_or_path = File.read data_or_path
              rescue Errno::ENOENT
                # couldn't read the file, it might be a string containing
                # a key
              rescue Errno::ENAMETOOLONG
                # the filename is too long, it's almost certainly a string
                # containing a key
              rescue => e
                raise TlsError::ReadDataError.new e, data_or_path
              end

              return data_or_path
            end

            # Attempt to exchange the TCP socket for a TLS socket.
            def start_tls
              write_message :StartTls
              expect_message :StartTls
              # Swap the tls socket in for the tcp socket, so write_message and
              # read_message continue working
              @sock = @tls = OpenSSL::SSL::SSLSocket.new @tcp, @context
              @tls.connect
            end

            # Validate the TLS session
            def validate_session
              if @auth[:verify_hostname] &&
                  !OpenSSL::SSL::verify_certificate_identity(riak_cert, @host)
                raise TlsError::CertHostMismatchError.new
              end

              unless (riak_cert.not_before..riak_cert.not_after).cover? Time.now
                raise TlsError::CertNotValidError.new
              end

              validator = CertValidator.new riak_cert, ca_cert

              validator.crl = try_load @auth[:crl_file] if @auth[:crl_file]

              raise TlsError::CertRevokedError.new if @auth[:crl] and !validator.crl_valid?
              raise TlsError::CertRevokedError.new if @auth[:ocsp] and !validator.ocsp_valid?
            end

            def validator_options
              o = {
                ocsp: !!@auth[:ocsp],
                crl: !!@auth[:crl]
              }

              if @auth[:crl_file]
                o[:crl_file] = @auth[:crl_file]
                o[:crl] = true
              end

              return o
            end

            # Send an AuthReq with the authentication data. Rely on beefcake
            # discarding message parts it doesn't understand.
            def send_authentication
              req = BC::RpbAuthReq.new @auth
              write_message :AuthReq, req.encode
              expect_message :AuthResp
            end

            # Ping the Riak node and make sure it actually works.
            def validate_connection
              write_message :PingReq
              expect_message :PingResp
            end

            # Write a protocol buffers message to whatever the current
            # socket is.
            def write_message(code, message = '')
              if code.is_a? Symbol
                code = BeefcakeMessageCodes.index code
              end

              header = [message.length+1, code].pack 'NC'
              @sock.write header + message
            end

            def read_message
              header = @sock.read 5
              raise TlsError.new(t('ssl.eof_during_init')) if header.nil?
              len, code = header.unpack 'NC'
              decode = BeefcakeMessageCodes[code]
              return decode, '' if len == 1

              message = @sock.read(len - 1)
              return decode, message
            end

            def expect_message(expected_code)
              if expected_code.is_a? Numeric
                expected_code = BeefcakeMessageCodes[code]
              end

              candidate_code, message = read_message
              return message if expected_code == candidate_code

              raise TlsError.new(t('ssl.unexpected_during_init',
                                   expected: expected_code.inspect,
                                   actual: candidate_code.inspect,
                                   body: message.inspect
                                   ))

            end
          end
        end
      end
    end
  end
end
