# frozen_string_literal: true require "forwardable" require "ipaddr" require "resolv" module HTTPX class Resolver include Loggable extend Forwardable # Maximum UDP packet we'll accept MAX_PACKET_SIZE = 512 DNS_PORT = 53 @mutex = Mutex.new @identifier = 1 def self.generate_id @mutex.synchronize { @identifier = (@identifier + 1) & 0xFFFF } end def self.nameservers Resolv::DNS::Config.default_config_hash[:nameserver] end def_delegator :@io, :closed? def initialize(options) @options = Options.new(options) # early return for edge case when there are no nameservers configured # but we still want to be able to static lookups using #resolve_hostname (@nameservers = self.class.nameservers) || return server = IPAddr.new(@nameservers.sample) @io = UDP.new(server, DNS_PORT) @read_buffer = "".b @addresses = {} @hostnames = [] @callbacks = [] @state = :idle end def to_io @io.to_io end def resolve(hostname, &action) if host = resolve_hostname(hostname) unless ip_address = resolve_host(host) raise Resolv::ResolvError, "invalid entry in hosts file: #{host}" end @addresses[hostname] = ip_address action.call(ip_address) end @hostnames << hostname @callbacks << action query = build_query(hostname).encode log { "resolving #{hostname}: #{query.inspect}" } siz = @io.write(query) log { "WRITE: #{siz} bytes..." } end def call return if @state == :closed return if @hostnames.empty? dread end def dread(wsize = MAX_PACKET_SIZE) loop do siz = @io.read(wsize, @read_buffer) throw(:close, self) unless siz return if siz.zero? log { "READ: #{siz} bytes..." } addrs = parse(@read_buffer) @read_buffer.clear next if addrs.empty? hostname = @hostnames.shift callback = @callbacks.shift addr = addrs.index(addrs.rand(addrs.size)) log { "resolved #{hostname}: #{addr}" } @addresses[hostname] = addr callback.call(addr) end end private def parse(frame) response = Resolv::DNS::Message.decode(frame) addrs = [] # The answer might include IN::CNAME entries so filters them out # to include IN::A & IN::AAAA entries only. response.each_answer { |_name, _ttl, value| addrs << value.address if value.respond_to?(:address) } addrs end def resolve_hostname(hostname) # Resolv::Hosts#getaddresses pushes onto a stack # so since we want the first occurance, simply # pop off the stack. resolv.getaddresses(hostname).pop rescue StandardError end def resolv @resolv ||= Resolv::Hosts.new end def build_query(hostname) Resolv::DNS::Message.new.tap do |query| query.id = self.class.generate_id query.rd = 1 query.add_question hostname, Resolv::DNS::Resource::IN::A end end def resolve_host(host) resolve_ip(Resolv::IPv4, host) || get_address(host) || resolve_ip(Resolv::IPv6, host) end def resolve_ip(klass, host) klass.create(host) rescue ArgumentError end private def get_address(host) Resolv::Hosts.new(host).getaddress rescue StandardError end end end