# frozen_string_literal: true
#
# Author:: Tim Dysinger (<tim@dysinger.net>)
# Author:: Benjamin Black (<bb@chef.io>)
# Author:: Christopher Brown (<cb@chef.io>)
# Copyright:: Copyright (c) Chef Software Inc.
# License:: Apache License, Version 2.0
#
# 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 "net/http" unless defined?(Net::HTTP)

module Ohai
  module Mixin
    ##
    # This code parses the EC2 Instance Metadata API to provide details
    # of the running instance.
    #
    # Earlier version of this code assumed a specific version of the
    # metadata API was available. Unfortunately the API versions
    # supported by a particular instance are determined at instance
    # launch and are not extended over the life of the instance. As such
    # the earlier code would fail depending on the age of the instance.
    #
    # The updated code probes the instance metadata endpoint for
    # available versions, determines the most advanced version known to
    # work and executes the metadata retrieval using that version.
    #
    # If no compatible version is found, an empty hash is returned.
    #
    module Ec2Metadata

      EC2_METADATA_ADDR ||= "169.254.169.254"
      EC2_SUPPORTED_VERSIONS ||= %w{ 1.0 2007-01-19 2007-03-01 2007-08-29 2007-10-10 2007-12-15
                                     2008-02-01 2008-09-01 2009-04-04 2011-01-01 2011-05-01 2012-01-12
                                     2014-02-25 2014-11-05 2015-10-20 2016-04-19 2016-06-30 2016-09-02
                                     2016-11-30 2018-08-17 2018-11-29 2019-10-01 2020-08-24 2020-11-01 }.freeze
      EC2_ARRAY_VALUES ||= %w{security-groups local_ipv4s}.freeze
      EC2_ARRAY_DIR    ||= %w{network/interfaces/macs}.freeze
      EC2_JSON_DIR     ||= %w{iam}.freeze

      #
      # The latest metadata version in EC2_SUPPORTED_VERSIONS that this instance supports
      # in AWS supported metadata versions are determined at instance start so we need to be
      # cautious here in case an instance has been running for a long time
      #
      # @return [String] the version
      #
      def best_api_version
        @api_version ||= begin
          logger.trace("Mixin EC2: Fetching http://#{EC2_METADATA_ADDR}/ to determine the latest supported metadata release")
          response = http_client.get("/", { 'X-aws-ec2-metadata-token': v2_token })
          if response.code == "404"
            logger.trace("Mixin EC2: Received HTTP 404 from metadata server while determining API version, assuming 'latest'")
            return "latest"
          elsif response.code != "200"
            raise "Mixin EC2: Unable to determine EC2 metadata version (returned #{response.code} response)"
          end
          # NOTE: Sorting the list of versions may have unintended consequences in
          # non-EC2 environments. It appears to be safe in EC2 as of 2013-04-12.
          versions = response.body.split("\n").sort
          until versions.empty? || EC2_SUPPORTED_VERSIONS.include?(versions.last)
            pv = versions.pop
            logger.trace("Mixin EC2: EC2 lists metadata version: #{pv} not yet supported by Ohai") unless pv == "latest"
          end
          logger.trace("Mixin EC2: Latest supported EC2 metadata version: #{versions.last}")
          if versions.empty?
            raise "Mixin EC2: Unable to determine EC2 metadata version (no supported entries found)"
          end

          versions.last
        end
      end

      # a net/http client with a timeout of 10s and a keepalive of 10s
      #
      # @return [Net::HTTP]
      def http_client
        @conn ||= Net::HTTP.start(EC2_METADATA_ADDR).tap do |h|
          h.read_timeout = 10
          h.keep_alive_timeout = 10
        end
      end

      #
      # Fetch an API token for use querying AWS IMDSv2 or return nil if no token if found
      # AWS like systems (think OpenStack) will not respond with a token here
      #
      # @return [NilClass, String] API token or nil
      #
      def v2_token
        @v2_token ||= begin
            request = http_client.put("/latest/api/token", nil, { 'X-aws-ec2-metadata-token-ttl-seconds': "60" })
            if request.code == "404" # not on AWS
              nil
            else
              request.body
            end
          end
      end

      # Get metadata for a given path and API version
      #
      # Typically, a 200 response is expected for valid metadata.
      # On certain instance types, traversing the provided metadata path
      # produces a 404 for some unknown reason. In that event, return
      # `nil` and continue the run instead of failing it.
      def metadata_get(id, api_version)
        path = "/#{api_version}/meta-data/#{id}"
        logger.trace("Mixin EC2: Fetching http://#{EC2_METADATA_ADDR}#{path}")
        response = http_client.get(path, { 'X-aws-ec2-metadata-token': v2_token })
        case response.code
        when "200"
          response.body
        when "404"
          logger.trace("Mixin EC2: Encountered 404 response retrieving EC2 metadata path: #{path} ; continuing.")
          nil
        else
          raise "Mixin EC2: Encountered error retrieving EC2 metadata (#{path} returned #{response.code} response)"
        end
      end

      def fetch_metadata(id = "", api_version = nil)
        metadata = {}
        retrieved_metadata = metadata_get(id, best_api_version)
        if retrieved_metadata
          retrieved_metadata.split("\n").each do |o|
            key = expand_path("#{id}#{o}")
            if key[-1..-1] != "/"
              metadata[metadata_key(key)] =
                if EC2_ARRAY_VALUES.include? key
                  retr_meta = metadata_get(key, best_api_version)
                  retr_meta ? retr_meta.split("\n") : retr_meta
                else
                  metadata_get(key, best_api_version)
                end
            elsif (!key.eql?(id)) && (!key.eql?("/"))
              name = key[0..-2]
              sym = metadata_key(name)
              if EC2_ARRAY_DIR.include?(name)
                metadata[sym] = fetch_dir_metadata(key, best_api_version)
              elsif EC2_JSON_DIR.include?(name)
                metadata[sym] = fetch_json_dir_metadata(key, best_api_version)
              else
                fetch_metadata(key, best_api_version).each { |k, v| metadata[k] = v }
              end
            end
          end
          metadata
        end
      end

      def fetch_dir_metadata(id, api_version)
        metadata = {}
        retrieved_metadata = metadata_get(id, api_version)
        if retrieved_metadata
          retrieved_metadata.split("\n").each do |o|
            key = expand_path(o)
            if key[-1..-1] != "/"
              retr_meta = metadata_get("#{id}#{key}", api_version)
              metadata[metadata_key(key)] = retr_meta || ""
            elsif !key.eql?("/")
              metadata[key[0..-2]] = fetch_dir_metadata("#{id}#{key}", api_version)
            end
          end
          metadata
        end
      end

      def fetch_json_dir_metadata(id, api_version)
        metadata = {}
        retrieved_metadata = metadata_get(id, api_version)
        if retrieved_metadata
          retrieved_metadata.split("\n").each do |o|
            key = expand_path(o)
            if key[-1..-1] != "/"
              retr_meta = metadata_get("#{id}#{key}", api_version)
              data = retr_meta || ""
              json = String(data)
              parser = FFI_Yajl::Parser.new
              metadata[metadata_key(key)] = parser.parse(json)
            elsif !key.eql?("/")
              metadata[key[0..-2]] = fetch_json_dir_metadata("#{id}#{key}", api_version)
            end
          end
          metadata
        end
      end

      def fetch_userdata
        logger.trace("Mixin EC2: Fetching http://#{EC2_METADATA_ADDR}/#{best_api_version}/user-data/")
        response = http_client.get("/#{best_api_version}/user-data/", { 'X-aws-ec2-metadata-token': v2_token })
        response.code == "200" ? response.body : nil
      end

      def fetch_dynamic_data
        @fetch_dynamic_data ||= begin
          response = http_client.get("/#{best_api_version}/dynamic/instance-identity/document/", { 'X-aws-ec2-metadata-token': v2_token })

          if json?(response.body) && response.code == "200"
            FFI_Yajl::Parser.parse(response.body)
          else
            {}
          end
        end
      end

      private

      def expand_path(file_name)
        path = file_name.gsub(/\=.*$/, "/")
        # ignore "./" and "../"
        path.gsub(%r{/\.\.?(?:/|$)}, "/")
          .sub(%r{^\.\.?(?:/|$)}, "")
          .sub(/^$/, "/")
      end

      def metadata_key(key)
        key.gsub(%r{\-|/}, "_")
      end

    end
  end
end
