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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +75 -0
- data/LICENSE +21 -0
- data/README.md +348 -0
- data/data/rdap_bootstrap.json +5337 -0
- data/data/whois_servers.json +1460 -0
- data/exe/monovm-whois +6 -0
- data/lib/monovm/whois/availability/analyzer.rb +91 -0
- data/lib/monovm/whois/availability/context.rb +137 -0
- data/lib/monovm/whois/availability/patterns.rb +415 -0
- data/lib/monovm/whois/availability/rule.rb +57 -0
- data/lib/monovm/whois/availability/rule_set.rb +137 -0
- data/lib/monovm/whois/availability/rules/availability_keywords.rb +32 -0
- data/lib/monovm/whois/availability/rules/explicit_unavailability.rb +43 -0
- data/lib/monovm/whois/availability/rules/no_match.rb +31 -0
- data/lib/monovm/whois/availability/rules/premium_name.rb +35 -0
- data/lib/monovm/whois/availability/rules/rdap_object.rb +94 -0
- data/lib/monovm/whois/availability/rules/recordless.rb +45 -0
- data/lib/monovm/whois/availability/rules/registration_fields.rb +37 -0
- data/lib/monovm/whois/availability/rules/registry_marker.rb +38 -0
- data/lib/monovm/whois/availability/rules/server_refusal.rb +46 -0
- data/lib/monovm/whois/availability/rules/status_field.rb +42 -0
- data/lib/monovm/whois/availability/rules/tld_specific.rb +38 -0
- data/lib/monovm/whois/availability/rules/wrong_registry.rb +48 -0
- data/lib/monovm/whois/availability/verdict.rb +100 -0
- data/lib/monovm/whois/checker.rb +165 -0
- data/lib/monovm/whois/cli.rb +250 -0
- data/lib/monovm/whois/client.rb +227 -0
- data/lib/monovm/whois/configuration.rb +160 -0
- data/lib/monovm/whois/domain_name.rb +168 -0
- data/lib/monovm/whois/endpoint.rb +131 -0
- data/lib/monovm/whois/errors.rb +63 -0
- data/lib/monovm/whois/parser/base.rb +126 -0
- data/lib/monovm/whois/parser/icann_rdd.rb +79 -0
- data/lib/monovm/whois/parser/key_value.rb +169 -0
- data/lib/monovm/whois/parser/rdap_json.rb +170 -0
- data/lib/monovm/whois/parser/record.rb +165 -0
- data/lib/monovm/whois/parser/selector.rb +74 -0
- data/lib/monovm/whois/paths.rb +31 -0
- data/lib/monovm/whois/punycode.rb +206 -0
- data/lib/monovm/whois/referral/follower.rb +90 -0
- data/lib/monovm/whois/registry/definition.rb +119 -0
- data/lib/monovm/whois/registry/resolution.rb +57 -0
- data/lib/monovm/whois/registry/server_registry.rb +164 -0
- data/lib/monovm/whois/registry/sources/base.rb +58 -0
- data/lib/monovm/whois/registry/sources/iana_bootstrap.rb +142 -0
- data/lib/monovm/whois/registry/sources/json_file.rb +137 -0
- data/lib/monovm/whois/response.rb +89 -0
- data/lib/monovm/whois/result.rb +114 -0
- data/lib/monovm/whois/transport/base.rb +51 -0
- data/lib/monovm/whois/transport/factory.rb +51 -0
- data/lib/monovm/whois/transport/middleware/base.rb +55 -0
- data/lib/monovm/whois/transport/middleware/cache.rb +92 -0
- data/lib/monovm/whois/transport/middleware/instrumentation.rb +63 -0
- data/lib/monovm/whois/transport/middleware/retry.rb +56 -0
- data/lib/monovm/whois/transport/middleware/throttle.rb +62 -0
- data/lib/monovm/whois/transport/rdap_http.rb +146 -0
- data/lib/monovm/whois/transport/whois_socket.rb +130 -0
- data/lib/monovm/whois/version.rb +7 -0
- data/lib/monovm/whois/whois_handler.rb +157 -0
- data/lib/monovm/whois.rb +142 -0
- data/lib/monovm-whois-ruby.rb +5 -0
- data/lib/monovm-whois.rb +5 -0
- 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
|