monovm-whois-ruby 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +75 -0
  3. data/LICENSE +21 -0
  4. data/README.md +348 -0
  5. data/data/rdap_bootstrap.json +5337 -0
  6. data/data/whois_servers.json +1460 -0
  7. data/exe/monovm-whois +6 -0
  8. data/lib/monovm/whois/availability/analyzer.rb +91 -0
  9. data/lib/monovm/whois/availability/context.rb +137 -0
  10. data/lib/monovm/whois/availability/patterns.rb +415 -0
  11. data/lib/monovm/whois/availability/rule.rb +57 -0
  12. data/lib/monovm/whois/availability/rule_set.rb +137 -0
  13. data/lib/monovm/whois/availability/rules/availability_keywords.rb +32 -0
  14. data/lib/monovm/whois/availability/rules/explicit_unavailability.rb +43 -0
  15. data/lib/monovm/whois/availability/rules/no_match.rb +31 -0
  16. data/lib/monovm/whois/availability/rules/premium_name.rb +35 -0
  17. data/lib/monovm/whois/availability/rules/rdap_object.rb +94 -0
  18. data/lib/monovm/whois/availability/rules/recordless.rb +45 -0
  19. data/lib/monovm/whois/availability/rules/registration_fields.rb +37 -0
  20. data/lib/monovm/whois/availability/rules/registry_marker.rb +38 -0
  21. data/lib/monovm/whois/availability/rules/server_refusal.rb +46 -0
  22. data/lib/monovm/whois/availability/rules/status_field.rb +42 -0
  23. data/lib/monovm/whois/availability/rules/tld_specific.rb +38 -0
  24. data/lib/monovm/whois/availability/rules/wrong_registry.rb +48 -0
  25. data/lib/monovm/whois/availability/verdict.rb +100 -0
  26. data/lib/monovm/whois/checker.rb +165 -0
  27. data/lib/monovm/whois/cli.rb +250 -0
  28. data/lib/monovm/whois/client.rb +227 -0
  29. data/lib/monovm/whois/configuration.rb +160 -0
  30. data/lib/monovm/whois/domain_name.rb +168 -0
  31. data/lib/monovm/whois/endpoint.rb +131 -0
  32. data/lib/monovm/whois/errors.rb +63 -0
  33. data/lib/monovm/whois/parser/base.rb +126 -0
  34. data/lib/monovm/whois/parser/icann_rdd.rb +79 -0
  35. data/lib/monovm/whois/parser/key_value.rb +169 -0
  36. data/lib/monovm/whois/parser/rdap_json.rb +170 -0
  37. data/lib/monovm/whois/parser/record.rb +165 -0
  38. data/lib/monovm/whois/parser/selector.rb +74 -0
  39. data/lib/monovm/whois/paths.rb +31 -0
  40. data/lib/monovm/whois/punycode.rb +206 -0
  41. data/lib/monovm/whois/referral/follower.rb +90 -0
  42. data/lib/monovm/whois/registry/definition.rb +119 -0
  43. data/lib/monovm/whois/registry/resolution.rb +57 -0
  44. data/lib/monovm/whois/registry/server_registry.rb +164 -0
  45. data/lib/monovm/whois/registry/sources/base.rb +58 -0
  46. data/lib/monovm/whois/registry/sources/iana_bootstrap.rb +142 -0
  47. data/lib/monovm/whois/registry/sources/json_file.rb +137 -0
  48. data/lib/monovm/whois/response.rb +89 -0
  49. data/lib/monovm/whois/result.rb +114 -0
  50. data/lib/monovm/whois/transport/base.rb +51 -0
  51. data/lib/monovm/whois/transport/factory.rb +51 -0
  52. data/lib/monovm/whois/transport/middleware/base.rb +55 -0
  53. data/lib/monovm/whois/transport/middleware/cache.rb +92 -0
  54. data/lib/monovm/whois/transport/middleware/instrumentation.rb +63 -0
  55. data/lib/monovm/whois/transport/middleware/retry.rb +56 -0
  56. data/lib/monovm/whois/transport/middleware/throttle.rb +62 -0
  57. data/lib/monovm/whois/transport/rdap_http.rb +146 -0
  58. data/lib/monovm/whois/transport/whois_socket.rb +130 -0
  59. data/lib/monovm/whois/version.rb +7 -0
  60. data/lib/monovm/whois/whois_handler.rb +157 -0
  61. data/lib/monovm/whois.rb +142 -0
  62. data/lib/monovm-whois-ruby.rb +5 -0
  63. data/lib/monovm-whois.rb +5 -0
  64. metadata +114 -0
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonoVM
4
+ module Whois
5
+ # What a lookup produced: the verdict, the parsed record, and the raw response
6
+ # that backs both.
7
+ #
8
+ # {#status} adds +:invalid+ to the four {Availability::Verdict} statuses, for
9
+ # names that never reached a server — malformed input, or a TLD with no known
10
+ # endpoint. That distinction matters to a caller building a UI: +:invalid+ is
11
+ # the user's problem to fix, +:unknown+ is ours to retry.
12
+ class Result
13
+ STATUSES = %i[available registered premium unknown invalid].freeze
14
+
15
+ attr_reader :domain, :sld, :tld, :status, :verdict, :record, :response, :error
16
+
17
+ class << self
18
+ # A name that could not be looked up at all.
19
+ def invalid(domain, reason:, sld: nil, tld: nil)
20
+ new(domain: domain, status: :invalid, sld: sld, tld: tld, error: reason)
21
+ end
22
+
23
+ # A lookup that reached a server but produced no verdict.
24
+ def unknown(domain, reason:, sld: nil, tld: nil, response: nil)
25
+ new(
26
+ domain: domain,
27
+ status: :unknown,
28
+ sld: sld,
29
+ tld: tld,
30
+ response: response,
31
+ error: reason,
32
+ verdict: Availability::Verdict.unknown(reason: reason)
33
+ )
34
+ end
35
+ end
36
+
37
+ def initialize(domain:, status:, sld: nil, tld: nil, verdict: nil, record: nil,
38
+ response: nil, error: nil)
39
+ raise ArgumentError, "unknown result status #{status.inspect}" unless STATUSES.include?(status)
40
+
41
+ @domain = domain
42
+ @status = status
43
+ @sld = sld
44
+ @tld = tld
45
+ @verdict = verdict
46
+ @record = record
47
+ @response = response
48
+ @error = error
49
+ freeze
50
+ end
51
+
52
+ def available?
53
+ status == :available
54
+ end
55
+
56
+ def registered?
57
+ status == :registered
58
+ end
59
+
60
+ def premium?
61
+ status == :premium
62
+ end
63
+
64
+ def unknown?
65
+ status == :unknown
66
+ end
67
+
68
+ def invalid?
69
+ status == :invalid
70
+ end
71
+
72
+ # True when the lookup produced an actionable answer about the domain.
73
+ def conclusive?
74
+ available? || registered? || premium?
75
+ end
76
+
77
+ # The name as a string, in its Unicode form.
78
+ def name
79
+ domain.to_s
80
+ end
81
+
82
+ # The raw text the registry sent, or the explanatory message when no server
83
+ # was reached. Also aliased as +getWhoisMessage+ on {WhoisHandler}.
84
+ def whois_message
85
+ return response.text if response
86
+
87
+ error.to_s
88
+ end
89
+ alias message whois_message
90
+
91
+ # Why the verdict came out this way, for logging and bug reports.
92
+ def reason
93
+ verdict&.reason || error
94
+ end
95
+
96
+ def to_h
97
+ {
98
+ domain: name,
99
+ sld: sld,
100
+ tld: tld,
101
+ status: status,
102
+ reason: reason,
103
+ rule: verdict&.rule,
104
+ record: record&.to_h,
105
+ response: response&.to_h
106
+ }.compact
107
+ end
108
+
109
+ def inspect
110
+ "#<#{self.class.name} #{name.inspect} #{status}>"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../response"
4
+ require_relative "../errors"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ module Transport
9
+ # The one interface every transport and every middleware implements.
10
+ #
11
+ # fetch(query:, endpoint:) -> Response
12
+ #
13
+ # Two implementations exist for two protocols ({WhoisSocket}, {RdapHttp}), and
14
+ # the middlewares in {Middleware} implement the same method so they can wrap
15
+ # either one. Because the whole surface is a single method, the specs inject a
16
+ # lambda-backed fake and the entire suite runs without a network.
17
+ #
18
+ # A transport decides nothing about availability. It returns what the server
19
+ # said, or raises: {ConnectionError} when the server was unreachable,
20
+ # {TimeoutError} when it was too slow, {ServerRefusedError} when it answered
21
+ # but declined to give a verdict, {EmptyResponseError} when it said nothing.
22
+ # That separation is what stops a network failure from being mistaken for an
23
+ # unregistered domain.
24
+ class Base
25
+ # @param query [String] the name to ask about, already in wire form
26
+ # @param endpoint [Endpoint] where to ask
27
+ # @return [Response]
28
+ def fetch(query:, endpoint:)
29
+ raise NotImplementedError, "#{self.class} must implement #fetch"
30
+ end
31
+
32
+ # True when this transport can serve +endpoint+.
33
+ #
34
+ # @param endpoint [Endpoint]
35
+ def supports?(endpoint)
36
+ raise NotImplementedError, "#{self.class} must implement #supports?"
37
+ end
38
+
39
+ private
40
+
41
+ # Wall-clock seconds for one exchange, recorded on the {Response} so callers
42
+ # can see which registry is slow without instrumenting the caller.
43
+ def measure
44
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ result = yield
46
+ [result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started]
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "whois_socket"
5
+ require_relative "rdap_http"
6
+
7
+ module MonoVM
8
+ module Whois
9
+ module Transport
10
+ # Chooses a transport for an endpoint.
11
+ #
12
+ # {Client} asks for "something that can fetch this endpoint" and gets it, so
13
+ # adding a protocol means registering a transport here and nowhere else. The
14
+ # factory also owns the middleware wrapping, which is why every transport it
15
+ # hands out already caches, throttles and retries consistently — those are
16
+ # decisions about the whole system, not about one protocol.
17
+ class Factory
18
+ attr_reader :transports, :middleware
19
+
20
+ # @param transports [Array<Base>] candidates, asked in order via #supports?
21
+ # @param middleware [Array<#call>] each receives a transport and returns a
22
+ # wrapped transport. Applied outermost-last, so the array reads in the
23
+ # order requests travel through it.
24
+ def initialize(transports: nil, middleware: [])
25
+ @transports = transports || [WhoisSocket.new, RdapHttp.new]
26
+ @middleware = middleware
27
+ @wrapped = {}
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ # @param endpoint [Endpoint]
32
+ # @return [Base] a transport that can serve +endpoint+, already wrapped
33
+ # @raise [Error] when nothing registered can serve it
34
+ def for(endpoint)
35
+ transport = transports.find { |candidate| candidate.supports?(endpoint) }
36
+ raise Error, "no transport can serve #{endpoint}" if transport.nil?
37
+
38
+ # Middleware holds per-host state (throttle clocks, cache entries), so each
39
+ # transport must be wrapped exactly once and shared, not re-wrapped per call.
40
+ @wrapped[transport] || @mutex.synchronize { @wrapped[transport] ||= wrap(transport) }
41
+ end
42
+
43
+ private
44
+
45
+ def wrap(transport)
46
+ middleware.reverse.reduce(transport) { |inner, layer| layer.call(inner) }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Transport
8
+ # Cross-cutting behaviour, wrapped around a transport rather than built into it.
9
+ #
10
+ # Caching, throttling and retrying are decisions about how a whole system
11
+ # talks to registries, not about how RDAP or port 43 works. Keeping them here
12
+ # means {WhoisSocket} and {RdapHttp} stay small enough to read, and a caller
13
+ # who wants a bare transport for one call can have one.
14
+ module Middleware
15
+ # Wraps another transport and forwards to it. Because it implements the same
16
+ # {Transport::Base} interface, layers compose in any order and nothing
17
+ # downstream can tell it is talking to a decorator.
18
+ class Base < Transport::Base
19
+ attr_reader :app
20
+
21
+ class << self
22
+ # A lambda suitable for {Factory}'s +middleware:+ list.
23
+ #
24
+ # Factory.new(middleware: [Middleware::Cache.builder(ttl: 300)])
25
+ def builder(**options)
26
+ ->(app) { new(app, **options) }
27
+ end
28
+ end
29
+
30
+ def initialize(app)
31
+ @app = app
32
+ super()
33
+ end
34
+
35
+ def fetch(query:, endpoint:)
36
+ app.fetch(query: query, endpoint: endpoint)
37
+ end
38
+
39
+ def supports?(endpoint)
40
+ app.supports?(endpoint)
41
+ end
42
+
43
+ # Reach the real transport through any stack of decorators.
44
+ def unwrap
45
+ app.is_a?(Base) ? app.unwrap : app
46
+ end
47
+
48
+ def inspect
49
+ "#<#{self.class.name} -> #{app.inspect}>"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Transport
8
+ module Middleware
9
+ # Remembers responses for a short while.
10
+ #
11
+ # Registries rate-limit, and a bulk check of a list that happens to contain
12
+ # the same name twice should not pay for it twice. The TTL is deliberately
13
+ # short by default: registration data changes, and a stale "available" is
14
+ # the expensive kind of wrong.
15
+ #
16
+ # Only successful responses are cached. A failure is never remembered — a
17
+ # transient timeout would otherwise poison every later lookup of that name.
18
+ class Cache < Middleware::Base
19
+ DEFAULT_TTL = 300.0
20
+ DEFAULT_MAX_ENTRIES = 1_000
21
+
22
+ attr_reader :ttl, :max_entries
23
+
24
+ # @param ttl [Float] seconds an entry stays fresh
25
+ # @param max_entries [Integer] cap on retained entries; oldest evicted first
26
+ # @param clock [#call] monotonic seconds, injectable so specs need no sleeps
27
+ def initialize(app, ttl: DEFAULT_TTL, max_entries: DEFAULT_MAX_ENTRIES, clock: nil)
28
+ @ttl = ttl
29
+ @max_entries = max_entries
30
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
31
+ @entries = {}
32
+ @mutex = Mutex.new
33
+ super(app)
34
+ end
35
+
36
+ def fetch(query:, endpoint:)
37
+ key = [endpoint.uri, query]
38
+
39
+ cached = read(key)
40
+ return cached if cached
41
+
42
+ response = app.fetch(query: query, endpoint: endpoint)
43
+ write(key, response)
44
+ response
45
+ end
46
+
47
+ # @return [Integer] how many fresh entries are held
48
+ def size
49
+ @mutex.synchronize { @entries.size }
50
+ end
51
+
52
+ def clear
53
+ @mutex.synchronize { @entries.clear }
54
+ self
55
+ end
56
+
57
+ private
58
+
59
+ def read(key)
60
+ @mutex.synchronize do
61
+ entry = @entries[key]
62
+ next nil if entry.nil?
63
+
64
+ if expired?(entry)
65
+ @entries.delete(key)
66
+ next nil
67
+ end
68
+
69
+ # Ruby hashes keep insertion order, so re-inserting makes this the
70
+ # newest entry and keeps eviction least-recently-used.
71
+ @entries.delete(key)
72
+ @entries[key] = entry
73
+ entry[:response]
74
+ end
75
+ end
76
+
77
+ def write(key, response)
78
+ @mutex.synchronize do
79
+ @entries.delete(key)
80
+ @entries[key] = { response: response, stored_at: @clock.call }
81
+ @entries.shift while @entries.size > max_entries
82
+ end
83
+ end
84
+
85
+ def expired?(entry)
86
+ @clock.call - entry[:stored_at] > ttl
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Transport
8
+ module Middleware
9
+ # Reports every exchange to a subscriber.
10
+ #
11
+ # A registrar running this at volume needs to know which registries are slow
12
+ # and which are refusing, and that is the host application's concern, not
13
+ # this library's. So nothing is logged here — an event is handed to whatever
14
+ # was injected, and the caller decides whether it becomes a log line, a
15
+ # StatsD counter or nothing at all.
16
+ class Instrumentation < Middleware::Base
17
+ # @param subscriber [#call] receives one Hash per exchange
18
+ def initialize(app, subscriber:)
19
+ @subscriber = subscriber
20
+ super(app)
21
+ end
22
+
23
+ def fetch(query:, endpoint:)
24
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+
26
+ begin
27
+ response = app.fetch(query: query, endpoint: endpoint)
28
+ rescue StandardError => e
29
+ publish(
30
+ query: query,
31
+ endpoint: endpoint,
32
+ outcome: :error,
33
+ error: e.class.name,
34
+ message: e.message,
35
+ elapsed: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
36
+ )
37
+ raise
38
+ end
39
+
40
+ publish(
41
+ query: query,
42
+ endpoint: endpoint,
43
+ outcome: :ok,
44
+ status: response.status,
45
+ bytes: response.body.length,
46
+ elapsed: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
47
+ )
48
+ response
49
+ end
50
+
51
+ private
52
+
53
+ def publish(event)
54
+ @subscriber.call(event.merge(endpoint: event[:endpoint].to_s, host: event[:endpoint].host))
55
+ rescue StandardError
56
+ # A broken metrics pipeline must never take a lookup down with it.
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../../errors"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ module Transport
9
+ module Middleware
10
+ # Retries failures that are plausibly transient.
11
+ #
12
+ # Only {ConnectionError} and {TimeoutError} qualify: a dropped TCP
13
+ # connection or a slow registry is worth one more attempt. A
14
+ # {ServerRefusedError} is not retried, and that restraint is the point — a
15
+ # refusal usually *is* a rate limit, so hammering it makes the block worse
16
+ # and delays every other lookup in the batch behind the backoff.
17
+ class Retry < Middleware::Base
18
+ DEFAULT_ATTEMPTS = 2
19
+ DEFAULT_BACKOFF = 0.5
20
+
21
+ RETRIABLE = [ConnectionError, TimeoutError].freeze
22
+
23
+ attr_reader :attempts, :backoff
24
+
25
+ # @param attempts [Integer] total tries, so 2 means one retry
26
+ # @param backoff [Float] seconds before the first retry, doubling after
27
+ # @param sleeper [#call] injectable so specs do not actually wait
28
+ def initialize(app, attempts: DEFAULT_ATTEMPTS, backoff: DEFAULT_BACKOFF, sleeper: nil)
29
+ @attempts = [attempts.to_i, 1].max
30
+ @backoff = backoff
31
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
32
+ super(app)
33
+ end
34
+
35
+ def fetch(query:, endpoint:)
36
+ attempt = 0
37
+ delay = backoff
38
+
39
+ loop do
40
+ attempt += 1
41
+
42
+ begin
43
+ return app.fetch(query: query, endpoint: endpoint)
44
+ rescue *RETRIABLE
45
+ raise if attempt >= attempts
46
+
47
+ @sleeper.call(delay)
48
+ delay *= 2
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Transport
8
+ module Middleware
9
+ # Keeps a minimum gap between consecutive queries to the same host.
10
+ #
11
+ # This is the politeness layer, and it is what makes concurrent bulk checks
12
+ # safe. Registries publish query-rate limits and enforce them by blocking
13
+ # clients; without a throttle, pointing eight threads at a list of +.com+
14
+ # names sends eight simultaneous queries to one Verisign host and gets the
15
+ # caller's IP throttled — after which every response is a refusal and no
16
+ # domain can be checked at all.
17
+ #
18
+ # The gap is per host, not global, so a batch spanning many TLDs still runs
19
+ # in parallel across them.
20
+ class Throttle < Middleware::Base
21
+ DEFAULT_INTERVAL = 0.5
22
+
23
+ attr_reader :interval
24
+
25
+ # @param interval [Float] minimum seconds between queries to one host
26
+ # @param clock [#call] monotonic seconds, injectable for specs
27
+ # @param sleeper [#call] receives seconds to wait, injectable for specs
28
+ def initialize(app, interval: DEFAULT_INTERVAL, clock: nil, sleeper: nil)
29
+ @interval = interval
30
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
31
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
32
+ @last_seen = {}
33
+ @mutex = Mutex.new
34
+ super(app)
35
+ end
36
+
37
+ def fetch(query:, endpoint:)
38
+ wait_for(endpoint.host)
39
+ app.fetch(query: query, endpoint: endpoint)
40
+ end
41
+
42
+ private
43
+
44
+ # Reserve this host's next slot while holding the lock, then sleep outside
45
+ # it. Sleeping under the mutex would serialise every host behind whichever
46
+ # one happened to be waiting.
47
+ def wait_for(host)
48
+ delay = @mutex.synchronize do
49
+ now = @clock.call
50
+ earliest = @last_seen[host]
51
+ slot = earliest.nil? ? now : [now, earliest + interval].max
52
+ @last_seen[host] = slot
53
+ slot - now
54
+ end
55
+
56
+ @sleeper.call(delay) if delay.positive?
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require_relative "base"
7
+ require_relative "../version"
8
+
9
+ module MonoVM
10
+ module Whois
11
+ module Transport
12
+ # RDAP over HTTPS (RFC 7480-7483).
13
+ #
14
+ # RDAP is the reason this library can be confident where a WHOIS-only one has
15
+ # to guess. A registered domain comes back as JSON with an +objectClassName+;
16
+ # an unregistered one comes back as HTTP 404 with an +errorCode+ of 404. Both
17
+ # are answers, so both are returned as a {Response} for the rules to read.
18
+ #
19
+ # What is *not* an answer gets raised instead: 401/403/405/406/429 mean the
20
+ # server refused us, and 5xx means it broke. Handing those bodies back would
21
+ # let an error page be pattern-matched into "available".
22
+ class RdapHttp < Base
23
+ DEFAULT_OPEN_TIMEOUT = 10.0
24
+ DEFAULT_READ_TIMEOUT = 30.0
25
+ MAX_REDIRECTS = 3
26
+
27
+ # 404 carries the "no such domain" answer, so it is deliberately absent.
28
+ REFUSAL_STATUSES = [401, 403, 405, 406, 429].freeze
29
+
30
+ ACCEPT = "application/rdap+json, application/json;q=0.9, */*;q=0.1"
31
+
32
+ attr_reader :open_timeout, :read_timeout, :verify_ssl, :user_agent
33
+
34
+ # @param verify_ssl [Boolean] verify TLS certificates. Defaults to true.
35
+ # A handful of registry endpoints do serve broken chains; those
36
+ # deployments can opt out explicitly rather than everyone losing the
37
+ # guarantee silently.
38
+ def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
39
+ verify_ssl: true, user_agent: nil)
40
+ @open_timeout = open_timeout
41
+ @read_timeout = read_timeout
42
+ @verify_ssl = verify_ssl
43
+ @user_agent = user_agent ||
44
+ "monovm-whois-ruby/#{MonoVM::Whois::VERSION} (+https://github.com/monovm/whois-ruby)"
45
+ super()
46
+ end
47
+
48
+ def supports?(endpoint)
49
+ endpoint.http?
50
+ end
51
+
52
+ # @return [Response]
53
+ # @raise [ConnectionError, TimeoutError, ServerRefusedError, EmptyResponseError]
54
+ def fetch(query:, endpoint:)
55
+ url = URI.parse(endpoint.url_for(query))
56
+ (response, elapsed) = measure { request(url, endpoint) }
57
+
58
+ Response.new(
59
+ body: response.body.to_s,
60
+ endpoint: endpoint,
61
+ query: query,
62
+ status: Integer(response.code),
63
+ elapsed: elapsed
64
+ )
65
+ rescue URI::InvalidURIError => e
66
+ raise ConnectionError, "cannot build an RDAP URL for #{query}: #{e.message}"
67
+ end
68
+
69
+ private
70
+
71
+ def request(url, endpoint, redirects_left = MAX_REDIRECTS)
72
+ response = perform(url, endpoint)
73
+
74
+ # RDAP registries do redirect: a registry may hand a name off to the
75
+ # registrar's own server, and the answer is at the new location.
76
+ if redirect?(response) && redirects_left.positive?
77
+ location = response["location"]
78
+ return request(URI.join(url, location), endpoint, redirects_left - 1) if location
79
+ end
80
+
81
+ verify_answerable!(response, endpoint)
82
+ response
83
+ end
84
+
85
+ def perform(url, endpoint)
86
+ http = build_http(url)
87
+ request = Net::HTTP::Get.new(url)
88
+ request["Accept"] = ACCEPT
89
+ request["User-Agent"] = user_agent
90
+
91
+ http.start { |connection| connection.request(request) }
92
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
93
+ raise TimeoutError, "#{endpoint.host} timed out after #{timeout_for(e)}s (#{e.class})"
94
+ rescue OpenSSL::SSL::SSLError => e
95
+ raise ConnectionError, "TLS handshake with #{endpoint.host} failed: #{e.message}"
96
+ rescue SocketError, SystemCallError, Net::HTTPBadResponse, Net::ProtocolError, IOError => e
97
+ raise ConnectionError, "cannot reach #{endpoint.host}: #{e.message}"
98
+ end
99
+
100
+ def build_http(url)
101
+ http = Net::HTTP.new(url.host, url.port)
102
+ http.use_ssl = url.scheme == "https"
103
+ http.open_timeout = open_timeout
104
+ http.read_timeout = read_timeout
105
+
106
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless verify_ssl
107
+
108
+ http
109
+ end
110
+
111
+ def redirect?(response)
112
+ response.is_a?(Net::HTTPRedirection)
113
+ end
114
+
115
+ # Separate "the server answered the question" from "the server declined".
116
+ def verify_answerable!(response, endpoint)
117
+ status = Integer(response.code)
118
+
119
+ if REFUSAL_STATUSES.include?(status)
120
+ raise ServerRefusedError.new(
121
+ "#{endpoint.host} refused the query with HTTP #{status} #{response.message}",
122
+ endpoint: endpoint
123
+ )
124
+ end
125
+
126
+ if status >= 500
127
+ raise ServerRefusedError.new(
128
+ "#{endpoint.host} failed with HTTP #{status} #{response.message}",
129
+ endpoint: endpoint
130
+ )
131
+ end
132
+
133
+ # A 404 with no body is still an answer in RDAP terms, but an empty 200 is
134
+ # not: there is nothing in it to classify either way.
135
+ return unless status == 200 && response.body.to_s.strip.empty?
136
+
137
+ raise EmptyResponseError, "#{endpoint.host} returned an empty 200 for the query"
138
+ end
139
+
140
+ def timeout_for(error)
141
+ error.is_a?(Net::OpenTimeout) ? open_timeout : read_timeout
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end