lib/aws-sdk-core/ecs_credentials.rb



# frozen_string_literal: true

require 'time'
require 'net/http'
require 'resolv'

module Aws
  # An auto-refreshing credential provider that loads credentials from
  # instances running in containers.
  #
  #     ecs_credentials = Aws::ECSCredentials.new(retries: 3)
  #     ec2 = Aws::EC2::Client.new(credentials: ecs_credentials)
  class ECSCredentials
    include CredentialProvider
    include RefreshingCredentials

    # @api private
    class Non200Response < RuntimeError; end

    # Raised when the token file cannot be read.
    class TokenFileReadError < RuntimeError; end

    # Raised when the token file is invalid.
    class InvalidTokenError < RuntimeError; end

    # These are the errors we trap when attempting to talk to the
    # instance metadata service.  Any of these imply the service
    # is not present, no responding or some other non-recoverable
    # error.
    # @api private
    NETWORK_ERRORS = [
      Errno::EHOSTUNREACH,
      Errno::ECONNREFUSED,
      Errno::EHOSTDOWN,
      Errno::ENETUNREACH,
      SocketError,
      Timeout::Error,
      Non200Response
    ].freeze

    # @param [Hash] options
    # @option options [Integer] :retries (5) Number of times to retry
    #   when retrieving credentials.
    # @option options [String] :ip_address ('169.254.170.2') This value is
    #   ignored if `endpoint` is set and `credential_path` is not set.
    # @option options [Integer] :port (80) This value is ignored if `endpoint`
    #   is set and `credential_path` is not set.
    # @option options [String] :credential_path By default, the value of the
    #   AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable.
    # @option options [String] :endpoint The container credential endpoint.
    #   By default, this is the value of the AWS_CONTAINER_CREDENTIALS_FULL_URI
    #   environment variable. This value is ignored if `credential_path` or
    #   ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'] is set.
    # @option options [Float] :http_open_timeout (5)
    # @option options [Float] :http_read_timeout (5)
    # @option options [Numeric, Proc] :delay By default, failures are retried
    #   with exponential back-off, i.e. `sleep(1.2 ** num_failures)`. You can
    #   pass a number of seconds to sleep between failed attempts, or
    #   a Proc that accepts the number of failures.
    # @option options [IO] :http_debug_output (nil) HTTP wire
    #   traces are sent to this object.  You can specify something
    #   like $stdout.
    # @option options [Callable] before_refresh Proc called before
    #   credentials are refreshed. `before_refresh` is called
    #   with an instance of this object when
    #   AWS credentials are required and need to be refreshed.
    def initialize(options = {})
      credential_path = options[:credential_path] ||
                        ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI']
      endpoint = options[:endpoint] ||
                 ENV['AWS_CONTAINER_CREDENTIALS_FULL_URI']
      initialize_uri(options, credential_path, endpoint)

      @retries = options[:retries] || 5
      @http_open_timeout = options[:http_open_timeout] || 5
      @http_read_timeout = options[:http_read_timeout] || 5
      @http_debug_output = options[:http_debug_output]
      @backoff = backoff(options[:backoff])
      @async_refresh = false
      super
    end

    # @return [Integer] The number of times to retry failed attempts to
    #   fetch credentials from the instance metadata service. Defaults to 0.
    attr_reader :retries

    private

    def initialize_uri(options, credential_path, endpoint)
      if credential_path
        initialize_relative_uri(options, credential_path)
      # Use FULL_URI/endpoint only if RELATIVE_URI/path is not set
      elsif endpoint
        initialize_full_uri(endpoint)
      else
        raise ArgumentError,
              'Cannot instantiate an ECS Credential Provider '\
              'without a credential path or endpoint.'
      end
    end

    def initialize_relative_uri(options, path)
      @host = options[:ip_address] || '169.254.170.2'
      @port = options[:port] || 80
      @scheme = 'http'
      @credential_path = path
    end

    def initialize_full_uri(endpoint)
      uri = URI.parse(endpoint)
      validate_full_uri_scheme!(uri)
      validate_full_uri!(uri)
      @host = uri.hostname
      @port = uri.port
      @scheme = uri.scheme
      @credential_path = uri.request_uri
    end

    def validate_full_uri_scheme!(full_uri)
      return if full_uri.is_a?(URI::HTTP) || full_uri.is_a?(URI::HTTPS)

      raise ArgumentError, "'#{full_uri}' must be a valid HTTP or HTTPS URI"
    end

    # Validate that the full URI is using a loopback address if scheme is http.
    def validate_full_uri!(full_uri)
      return unless full_uri.scheme == 'http'

      begin
        return if valid_ip_address?(IPAddr.new(full_uri.host))
      rescue IPAddr::InvalidAddressError
        addresses = Resolv.getaddresses(full_uri.host)
        return if addresses.all? { |addr| valid_ip_address?(IPAddr.new(addr)) }
      end

      raise ArgumentError,
            'AWS_CONTAINER_CREDENTIALS_FULL_URI must use a local loopback '\
            'or an ECS or EKS link-local address when using the http scheme.'
    end

    def valid_ip_address?(ip_address)
      ip_loopback?(ip_address) || ecs_or_eks_ip?(ip_address)
    end

    # loopback? method is available in Ruby 2.5+
    # Replicate the logic here.
    # loopback (IPv4 127.0.0.0/8, IPv6 ::1/128)
    def ip_loopback?(ip_address)
      case ip_address.family
      when Socket::AF_INET
        ip_address & 0xff000000 == 0x7f000000
      when Socket::AF_INET6
        ip_address == 1
      else
        false
      end
    end

    # Verify that the IP address is a link-local address from ECS or EKS.
    # ECS container host (IPv4 `169.254.170.2`)
    # EKS container host (IPv4 `169.254.170.23`, IPv6 `fd00:ec2::23`)
    def ecs_or_eks_ip?(ip_address)
      case ip_address.family
      when Socket::AF_INET
        [0xa9feaa02, 0xa9feaa17].include?(ip_address)
      when Socket::AF_INET6
        ip_address == 0xfd00_0ec2_0000_0000_0000_0000_0000_0023
      else
        false
      end
    end

    def backoff(backoff)
      case backoff
      when Proc then backoff
      when Numeric then ->(_) { sleep(backoff) }
      else ->(num_failures) { Kernel.sleep(1.2**num_failures) }
      end
    end

    def refresh
      # Retry loading credentials up to 3 times is the instance metadata
      # service is responding but is returning invalid JSON documents
      # in response to the GET profile credentials call.

      retry_errors([Aws::Json::ParseError, StandardError], max_retries: 3) do
        c = Aws::Json.load(get_credentials.to_s)
        @credentials = Credentials.new(
          c['AccessKeyId'],
          c['SecretAccessKey'],
          c['Token']
        )
        @expiration = c['Expiration'] ? Time.iso8601(c['Expiration']) : nil
      end
    rescue Aws::Json::ParseError
      raise Aws::Errors::MetadataParserError
    end

    def get_credentials
      # Retry loading credentials a configurable number of times if
      # the instance metadata service is not responding.

      retry_errors(NETWORK_ERRORS, max_retries: @retries) do
        open_connection do |conn|
          http_get(conn, @credential_path)
        end
      end
    rescue TokenFileReadError, InvalidTokenError
      raise
    rescue StandardError => e
      warn("Error retrieving ECS Credentials: #{e.message}")
      '{}'
    end

    def fetch_authorization_token
      if (path = ENV['AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE'])
        fetch_authorization_token_file(path)
      elsif (token = ENV['AWS_CONTAINER_AUTHORIZATION_TOKEN'])
        token
      end
    end

    def fetch_authorization_token_file(path)
      File.read(path).strip
    rescue Errno::ENOENT
      raise TokenFileReadError,
            'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE is set '\
            "but the file doesn't exist: #{path}"
    end

    def validate_authorization_token!(token)
      return unless token.include?("\r\n")

      raise InvalidTokenError,
            'Invalid Authorization token: token contains '\
            'a newline and carriage return character.'
    end

    def open_connection
      http = Net::HTTP.new(@host, @port, nil)
      http.open_timeout = @http_open_timeout
      http.read_timeout = @http_read_timeout
      http.set_debug_output(@http_debug_output) if @http_debug_output
      http.use_ssl = @scheme == 'https'
      http.start
      yield(http).tap { http.finish }
    end

    def http_get(connection, path)
      request = Net::HTTP::Get.new(path)
      set_authorization_token(request)
      response = connection.request(request)
      raise Non200Response unless response.code.to_i == 200

      response.body
    end

    def set_authorization_token(request)
      if (authorization_token = fetch_authorization_token)
        validate_authorization_token!(authorization_token)
        request['Authorization'] = authorization_token
      end
    end

    def retry_errors(error_classes, options = {})
      max_retries = options[:max_retries]
      retries = 0
      begin
        yield
      rescue TokenFileReadError, InvalidTokenError
        raise
      rescue *error_classes => _e
        raise unless retries < max_retries

        @backoff.call(retries)
        retries += 1
        retry
      end
    end
  end
end