Module: RDAP

Defined in:
lib/rdap.rb

Defined Under Namespace

Classes: ConnectionError, EmptyResponse, Error, InvalidResponse, NotFound, SSLError, ServerError, TooManyRequests

Constant Summary collapse

VERSION =
"1.0.1"
BOOTSTRAP =
"https://rdap.org/"
TYPES =
[:domain, :ip, :autnum].freeze
HEADERS =
{
  "User-Agent" => "RDAP ruby gem (#{VERSION})",
  "Accept" => "application/rdap+json, application/json, */*;q=0.8",
  # Had to include other types here because rdap.nic.fr returns an error with only rdap+json -_-
}

Class Method Summary collapse

Class Method Details

.as(name, server: nil, **opts) ⇒ Object

Raises:

  • (ArgumentError)


36
37
38
39
40
41
# File 'lib/rdap.rb', line 36

def self.as name, server: nil, **opts
  asn = name.to_i if name.is_a?(Integer) || name.to_s =~ /\A\d+\z/
  raise ArgumentError.new("RDAP: Invalid AS number: #{name.inspect}") unless asn
  server ||= asn_index.find { |range, _| range.cover?(asn) }&.last
  query name, type: :autnum, server: server, **opts
end

.asn_indexObject

[AS range, RDAP server] pairs, built once so the ranges are parsed a single time rather than on every lookup.



72
73
74
75
76
# File 'lib/rdap.rb', line 72

def self.asn_index
  @asn_index ||= bootstrap('asn').flat_map do |ranges, urls|
    ranges.map { |range| low, high = range.split('-'); [(low.to_i..(high || low).to_i), urls.first] }
  end
end

.bootstrap(registry) ⇒ Object

Service entries from a bundled IANA bootstrap file (https://data.iana.org/rdap/).



79
80
81
# File 'lib/rdap.rb', line 79

def self.bootstrap registry
  JSON.parse(File.read(File.expand_path("../data/#{registry}.json", __dir__)))["services"]
end

.dns_indexObject

TLD => RDAP server, built once for O(1) domain lookups. IANA lists the preferred (https) endpoint first, so urls.first is the server to use.



56
57
58
59
60
# File 'lib/rdap.rb', line 56

def self.dns_index
  @dns_index ||= bootstrap('dns').each_with_object({}) do |(labels, urls), index|
    labels.each { |label| index[label.downcase] = urls.first }
  end
end

.domain(name, server: nil, **opts) ⇒ Object



24
25
26
27
28
# File 'lib/rdap.rb', line 24

def self.domain name, server: nil, **opts
  tld = name.to_s.chomp('.').split('.').last
  server ||= dns_index[tld.downcase] if tld
  query name, type: :domain, server: server, **opts
end

.get_follow_redirects(uri, timeout: 5, headers: {}, redirection_limit: 5) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/rdap.rb', line 83

def self.get_follow_redirects uri, timeout: 5, headers: {}, redirection_limit: 5
  raise ServerError.new("Too many redirections (> #{redirection_limit}) at #{uri}") if redirection_limit == 0

  http = Net::HTTP.new(uri.host, uri.port)
  http.open_timeout = timeout
  http.read_timeout = timeout
  http.use_ssl = true
  http.verify_mode = OpenSSL::SSL::VERIFY_PEER

  response = http.get(uri.path, HEADERS.merge(headers))
  case response
  when Net::HTTPSuccess
    if !response.body
      raise EmptyResponse.new("[#{response.code}] #{response.message}")
    end
    document = JSON.parse(response.body)
    if document["errorCode"]
      raise ServerError.new("[#{document["errorCode"]}] #{document["title"]} (#{uri})")
    end
    document
  when Net::HTTPNotFound
    # 404 sometimes return details in the JSON body so we threat them later
    if response.body.size > 0 and (document = JSON.parse(response.body) rescue nil)
      raise NotFound.new("[#{document["errorCode"]}] #{document["title"]}")
    else
      raise NotFound.new("[#{response.code}] #{response.message}")
    end
  when Net::HTTPTooManyRequests
    raise TooManyRequests.new("[#{response.code}] #{response.message}")
  when Net::HTTPRedirection
    get_follow_redirects(URI(response["location"]), timeout: timeout, headers: headers, redirection_limit: redirection_limit - 1)
  else
    raise ServerError.new("[#{response.code}] #{response.message}")
  end
rescue OpenSSL::SSL::SSLError => e
  raise SSLError.new("#{e.message} (#{uri.host})")
rescue JSON::ParserError => e
  raise InvalidResponse.new("JSON parser error: #{e.message}")
rescue Timeout::Error, IOError, SocketError, SystemCallError => e
  # Transport failures (timeouts, DNS, connection reset/refused, EOF, ...)
  raise ConnectionError.new("#{e.class}: #{e.message} (#{uri.host})")
end

.ip(name, server: nil, **opts) ⇒ Object



30
31
32
33
34
# File 'lib/rdap.rb', line 30

def self.ip name, server: nil, **opts
  ip = IPAddr.new(name.to_s)
  server ||= ip_index(ip.ipv6? ? 'ipv6' : 'ipv4').find { |net, _| net.include?(ip) }&.last
  query name, type: :ip, server: server, **opts
end

.ip_index(registry) ⇒ Object

[network, RDAP server] pairs for a registry, built once so the CIDRs are parsed into IPAddr objects a single time rather than on every lookup.



64
65
66
67
68
# File 'lib/rdap.rb', line 64

def self.ip_index registry
  (@ip_index ||= {})[registry] ||= bootstrap(registry).flat_map do |cidrs, urls|
    cidrs.map { |cidr| [IPAddr.new(cidr), urls.first] }
  end
end

.query(name, type:, timeout: 5, server: nil, headers: {}) ⇒ Object



43
44
45
46
47
48
49
50
# File 'lib/rdap.rb', line 43

def self.query name, type:, timeout: 5, server: nil, headers: {}
  TYPES.include?(type) or raise ArgumentError.new("RDAP: Invalid query type: #{type}, supported types: #{TYPES}")
  # .domain/.ip/.as resolve the authoritative server from the bundled IANA
  # files; fall back to the public bootstrap server for anything not covered.
  server ||= BOOTSTRAP
  uri = URI("#{server.chomp('/')}/#{type}/#{name}")
  get_follow_redirects(uri, timeout: timeout, headers: headers)
end