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,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
require_relative "domain_name"
|
|
5
|
+
require_relative "result"
|
|
6
|
+
require_relative "parser/record"
|
|
7
|
+
require_relative "referral/follower"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module MonoVM
|
|
11
|
+
module Whois
|
|
12
|
+
# Looks up one domain, end to end.
|
|
13
|
+
#
|
|
14
|
+
# The client owns the *sequence* — resolve the TLD, pick an endpoint, fetch,
|
|
15
|
+
# classify, parse, maybe follow a referral — and none of the policy. Which server
|
|
16
|
+
# serves a TLD is the registry's business, how to talk to it is the transport's,
|
|
17
|
+
# what the answer means is the analyzer's, and what the record says is the
|
|
18
|
+
# parser's. Every one of them arrives by injection, so a spec can replace any
|
|
19
|
+
# single step without touching the others.
|
|
20
|
+
#
|
|
21
|
+
# client = MonoVM::Whois::Client.new
|
|
22
|
+
# client.lookup("monovm.com").status # => :registered
|
|
23
|
+
class Client
|
|
24
|
+
# Fields where the registrar's copy is the better one, used when a thin
|
|
25
|
+
# registry's record is merged with the registrar's. Everywhere else the registry
|
|
26
|
+
# wins, because it is authoritative for whether the name exists, what state it is
|
|
27
|
+
# in and when it expires — and registrars are frequently stale on exactly those.
|
|
28
|
+
# The registrant and the contact block are the reverse: a thin registry never has
|
|
29
|
+
# them at all.
|
|
30
|
+
REGISTRAR_WINS = %i[registrant contacts].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :config, :server_registry, :transport_factory, :analyzer, :parsers, :follower
|
|
33
|
+
|
|
34
|
+
# Every argument defaults from +config+; pass one to replace that collaborator.
|
|
35
|
+
def initialize(config: nil, server_registry: nil, transport_factory: nil,
|
|
36
|
+
analyzer: nil, parsers: nil, follower: nil)
|
|
37
|
+
@config = config || Configuration.new
|
|
38
|
+
@server_registry = server_registry || @config.server_registry
|
|
39
|
+
@transport_factory = transport_factory || @config.transport_factory
|
|
40
|
+
@analyzer = analyzer || @config.analyzer
|
|
41
|
+
@parsers = parsers || @config.parsers
|
|
42
|
+
@follower = follower || default_follower
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param domain [String, DomainName]
|
|
46
|
+
# @return [Result] never raises for a lookup failure; the failure is the result
|
|
47
|
+
def lookup(domain)
|
|
48
|
+
name = domain.is_a?(DomainName) ? domain : DomainName.parse(domain)
|
|
49
|
+
|
|
50
|
+
return Result.invalid(name, reason: "#{name} is not a valid domain name") unless name.valid?
|
|
51
|
+
|
|
52
|
+
if name.bare?
|
|
53
|
+
return Result.invalid(
|
|
54
|
+
name,
|
|
55
|
+
reason: "#{name} has no TLD; give one, or use Checker to try popular TLDs"
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
resolution = server_registry.resolve(name)
|
|
60
|
+
return Result.invalid(name, reason: "no WHOIS or RDAP server is known for #{name}") if resolution.nil?
|
|
61
|
+
|
|
62
|
+
perform(name, resolution)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] true only when the lookup positively established availability
|
|
66
|
+
def available?(domain)
|
|
67
|
+
lookup(domain).available?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Why a lookup came out the way it did, including every rule consulted.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash]
|
|
73
|
+
def explain(domain)
|
|
74
|
+
result = lookup(domain)
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
domain: result.name,
|
|
78
|
+
status: result.status,
|
|
79
|
+
tld: result.tld,
|
|
80
|
+
reason: result.reason,
|
|
81
|
+
trace: result.verdict&.trace || [],
|
|
82
|
+
endpoint: result.response&.endpoint&.to_s,
|
|
83
|
+
record: result.record&.to_h
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def default_follower
|
|
90
|
+
return nil unless config.follow_referrals
|
|
91
|
+
|
|
92
|
+
Referral::Follower.new(transport_factory: transport_factory, max_hops: config.max_referrals)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def perform(name, resolution)
|
|
96
|
+
response, failure = fetch_first(resolution)
|
|
97
|
+
|
|
98
|
+
return failure_result(name, resolution, failure) if response.nil?
|
|
99
|
+
|
|
100
|
+
verdict = analyzer.analyze(
|
|
101
|
+
response: response,
|
|
102
|
+
tld: resolution.tld,
|
|
103
|
+
definition: resolution.definition
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
record = parsers.parse(response)
|
|
107
|
+
record, response = enrich(record, response, resolution, verdict)
|
|
108
|
+
|
|
109
|
+
Result.new(
|
|
110
|
+
domain: name,
|
|
111
|
+
status: verdict.status,
|
|
112
|
+
sld: resolution.sld,
|
|
113
|
+
tld: resolution.tld,
|
|
114
|
+
verdict: verdict,
|
|
115
|
+
record: record.empty? ? nil : record,
|
|
116
|
+
response: response
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Try the definition's endpoints in preference order, keeping the first answer.
|
|
121
|
+
#
|
|
122
|
+
# Falling through to port 43 when RDAP is unreachable is what keeps coverage
|
|
123
|
+
# high: a registry that has an RDAP entry in the IANA bootstrap but whose
|
|
124
|
+
# endpoint is broken still gets answered.
|
|
125
|
+
#
|
|
126
|
+
# @return [Array(Response, nil), Array(nil, Exception)]
|
|
127
|
+
def fetch_first(resolution)
|
|
128
|
+
endpoints = resolution.definition.endpoints(prefer: config.prefer)
|
|
129
|
+
return [nil, UnsupportedTldError.new(resolution.tld)] if endpoints.empty?
|
|
130
|
+
|
|
131
|
+
first_failure = nil
|
|
132
|
+
|
|
133
|
+
endpoints.each do |endpoint|
|
|
134
|
+
return [fetch(endpoint, resolution), nil]
|
|
135
|
+
rescue Error => e
|
|
136
|
+
# Remember the first failure: it came from the preferred endpoint and is
|
|
137
|
+
# the more relevant one to report.
|
|
138
|
+
first_failure ||= e
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
[nil, first_failure]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def fetch(endpoint, resolution)
|
|
145
|
+
transport_factory.for(endpoint).fetch(
|
|
146
|
+
query: query_for(endpoint, resolution),
|
|
147
|
+
endpoint: endpoint
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Punycode by default; the Unicode form for the few registries that insist on
|
|
152
|
+
# it, and only over port 43 since a URL must be ASCII either way.
|
|
153
|
+
def query_for(endpoint, resolution)
|
|
154
|
+
if endpoint.socket? && config.unicode_query?(resolution.tld)
|
|
155
|
+
resolution.registrable.to_s
|
|
156
|
+
else
|
|
157
|
+
resolution.query
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Follow a thin registry's referral to fill in the record, keeping the
|
|
162
|
+
# registry's verdict either way.
|
|
163
|
+
def enrich(record, response, resolution, verdict)
|
|
164
|
+
return [record, response] if follower.nil?
|
|
165
|
+
return [record, response] unless verdict.registered?
|
|
166
|
+
|
|
167
|
+
referral = follower.follow(response: response, record: record, query: resolution.query)
|
|
168
|
+
return [record, response] if referral.nil?
|
|
169
|
+
|
|
170
|
+
enriched = parsers.parse(referral)
|
|
171
|
+
return [record, response] if enriched.empty?
|
|
172
|
+
|
|
173
|
+
[merge_records(record, enriched), referral]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Combine the registry's record with the registrar's, one policy stated once
|
|
177
|
+
# rather than a conditional per field.
|
|
178
|
+
def merge_records(registry_record, registrar_record)
|
|
179
|
+
registry = registry_record.attributes
|
|
180
|
+
registrar = registrar_record.attributes
|
|
181
|
+
|
|
182
|
+
merged = registrar.merge(registry) do |key, from_registrar, from_registry|
|
|
183
|
+
preferred, fallback =
|
|
184
|
+
if REGISTRAR_WINS.include?(key)
|
|
185
|
+
[from_registrar, from_registry]
|
|
186
|
+
else
|
|
187
|
+
[from_registry, from_registrar]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
blank_value?(preferred) ? fallback : preferred
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Three fields are not a choice between two values.
|
|
194
|
+
merged[:registrar_whois_server] = registry[:registrar_whois_server]
|
|
195
|
+
merged[:fields] = registrar[:fields].merge(registry[:fields])
|
|
196
|
+
merged[:source] = [registry_record.source, registrar_record.source].compact.uniq.join("+")
|
|
197
|
+
|
|
198
|
+
Parser::Record.new(**merged)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def blank_value?(value)
|
|
202
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Map a transport failure onto a result. The distinction being preserved here:
|
|
206
|
+
# "we could not find out" is not "it is free".
|
|
207
|
+
def failure_result(name, resolution, failure)
|
|
208
|
+
case failure
|
|
209
|
+
when UnsupportedTldError
|
|
210
|
+
Result.invalid(
|
|
211
|
+
name,
|
|
212
|
+
reason: failure.message,
|
|
213
|
+
sld: resolution.sld,
|
|
214
|
+
tld: resolution.tld
|
|
215
|
+
)
|
|
216
|
+
else
|
|
217
|
+
Result.unknown(
|
|
218
|
+
name,
|
|
219
|
+
reason: failure&.message || "the lookup produced no response",
|
|
220
|
+
sld: resolution.sld,
|
|
221
|
+
tld: resolution.tld
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry/server_registry"
|
|
4
|
+
require_relative "transport/factory"
|
|
5
|
+
require_relative "transport/middleware/cache"
|
|
6
|
+
require_relative "transport/middleware/throttle"
|
|
7
|
+
require_relative "transport/middleware/retry"
|
|
8
|
+
require_relative "transport/middleware/instrumentation"
|
|
9
|
+
require_relative "availability/analyzer"
|
|
10
|
+
require_relative "availability/rule_set"
|
|
11
|
+
require_relative "parser/selector"
|
|
12
|
+
require_relative "paths"
|
|
13
|
+
|
|
14
|
+
module MonoVM
|
|
15
|
+
module Whois
|
|
16
|
+
# Every knob in one place, plus the defaults for the objects {Client} depends on.
|
|
17
|
+
#
|
|
18
|
+
# {Client} takes its collaborators by injection, which is what makes it testable
|
|
19
|
+
# — but nobody wants to assemble a registry, a transport factory, an analyzer and
|
|
20
|
+
# a parser selector just to look up one domain. This class is the seam between
|
|
21
|
+
# those two needs: it holds the settings and builds sensible collaborators from
|
|
22
|
+
# them, while leaving every one of them replaceable.
|
|
23
|
+
#
|
|
24
|
+
# MonoVM::Whois.configure do |config|
|
|
25
|
+
# config.prefer = :whois
|
|
26
|
+
# config.throttle_interval = 1.0
|
|
27
|
+
# config.rules.insert_before("registry_marker", MyRule.new)
|
|
28
|
+
# end
|
|
29
|
+
class Configuration
|
|
30
|
+
# TLDs whose registry wants an internationalised name in Unicode rather than
|
|
31
|
+
# Punycode, over port 43.
|
|
32
|
+
#
|
|
33
|
+
# DENIC answers +Status: invalid+ for +xn--mnchen-3ya.de+ but resolves
|
|
34
|
+
# +münchen.de+ correctly. Almost every other registry is the reverse — Verisign
|
|
35
|
+
# answers "No match" to a UTF-8 query, which reads as availability — so
|
|
36
|
+
# Punycode is the default and this is the short exception list. URLs are always
|
|
37
|
+
# ASCII, so it never applies to RDAP.
|
|
38
|
+
DEFAULT_UNICODE_QUERY_TLDS = [".de"].freeze
|
|
39
|
+
|
|
40
|
+
# Tried in order when a name arrives without a TLD.
|
|
41
|
+
DEFAULT_POPULAR_TLDS = [".com", ".net", ".org", ".info"].freeze
|
|
42
|
+
|
|
43
|
+
# Transport
|
|
44
|
+
attr_accessor :socket_connect_timeout, :socket_read_timeout,
|
|
45
|
+
:http_open_timeout, :http_read_timeout, :verify_ssl, :user_agent
|
|
46
|
+
|
|
47
|
+
# Behaviour
|
|
48
|
+
attr_accessor :prefer, :follow_referrals, :max_referrals, :unicode_query_tlds,
|
|
49
|
+
:popular_tlds, :concurrency, :override_path
|
|
50
|
+
|
|
51
|
+
# Middleware
|
|
52
|
+
attr_accessor :cache, :cache_ttl, :cache_max_entries,
|
|
53
|
+
:throttle_interval, :retry_attempts, :retry_backoff, :instrumentation
|
|
54
|
+
|
|
55
|
+
# Injected collaborators; each defaults lazily and can be replaced outright.
|
|
56
|
+
attr_writer :server_registry, :transport_factory, :analyzer, :rules, :parsers
|
|
57
|
+
|
|
58
|
+
def initialize
|
|
59
|
+
@socket_connect_timeout = Transport::WhoisSocket::DEFAULT_CONNECT_TIMEOUT
|
|
60
|
+
@socket_read_timeout = Transport::WhoisSocket::DEFAULT_READ_TIMEOUT
|
|
61
|
+
@http_open_timeout = Transport::RdapHttp::DEFAULT_OPEN_TIMEOUT
|
|
62
|
+
@http_read_timeout = Transport::RdapHttp::DEFAULT_READ_TIMEOUT
|
|
63
|
+
@verify_ssl = true
|
|
64
|
+
@user_agent = nil
|
|
65
|
+
|
|
66
|
+
# RDAP first: a structured answer beats pattern-matching registry prose.
|
|
67
|
+
@prefer = :rdap
|
|
68
|
+
@follow_referrals = true
|
|
69
|
+
@max_referrals = 1
|
|
70
|
+
@unicode_query_tlds = DEFAULT_UNICODE_QUERY_TLDS.dup
|
|
71
|
+
@popular_tlds = DEFAULT_POPULAR_TLDS.dup
|
|
72
|
+
@concurrency = 8
|
|
73
|
+
@override_path = Paths.override
|
|
74
|
+
|
|
75
|
+
@cache = true
|
|
76
|
+
@cache_ttl = Transport::Middleware::Cache::DEFAULT_TTL
|
|
77
|
+
@cache_max_entries = Transport::Middleware::Cache::DEFAULT_MAX_ENTRIES
|
|
78
|
+
@throttle_interval = Transport::Middleware::Throttle::DEFAULT_INTERVAL
|
|
79
|
+
@retry_attempts = Transport::Middleware::Retry::DEFAULT_ATTEMPTS
|
|
80
|
+
@retry_backoff = Transport::Middleware::Retry::DEFAULT_BACKOFF
|
|
81
|
+
@instrumentation = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def server_registry
|
|
85
|
+
@server_registry ||= Registry::ServerRegistry.default(override_path: override_path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def rules
|
|
89
|
+
@rules ||= Availability::RuleSet.default
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def analyzer
|
|
93
|
+
@analyzer ||= Availability::Analyzer.new(rules: rules)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parsers
|
|
97
|
+
@parsers ||= Parser::Selector.default
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def transport_factory
|
|
101
|
+
@transport_factory ||= Transport::Factory.new(
|
|
102
|
+
transports: default_transports,
|
|
103
|
+
middleware: default_middleware
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# True when +tld+'s registry wants the Unicode form over port 43.
|
|
108
|
+
def unicode_query?(tld)
|
|
109
|
+
unicode_query_tlds.include?(tld)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A copy, so a per-call override cannot mutate the global default.
|
|
113
|
+
def dup
|
|
114
|
+
copy = super
|
|
115
|
+
copy.unicode_query_tlds = unicode_query_tlds.dup
|
|
116
|
+
copy.popular_tlds = popular_tlds.dup
|
|
117
|
+
copy
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def default_transports
|
|
123
|
+
[
|
|
124
|
+
Transport::WhoisSocket.new(
|
|
125
|
+
connect_timeout: socket_connect_timeout,
|
|
126
|
+
read_timeout: socket_read_timeout
|
|
127
|
+
),
|
|
128
|
+
Transport::RdapHttp.new(
|
|
129
|
+
open_timeout: http_open_timeout,
|
|
130
|
+
read_timeout: http_read_timeout,
|
|
131
|
+
verify_ssl: verify_ssl,
|
|
132
|
+
user_agent: user_agent
|
|
133
|
+
)
|
|
134
|
+
]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Order matters, and it reads outermost first. Instrumentation sees everything
|
|
138
|
+
# including cache hits; the cache answers before a throttle delay is paid;
|
|
139
|
+
# retries happen closest to the wire so a retried attempt is still throttled.
|
|
140
|
+
def default_middleware
|
|
141
|
+
layers = []
|
|
142
|
+
if instrumentation
|
|
143
|
+
layers << Transport::Middleware::Instrumentation.builder(subscriber: instrumentation)
|
|
144
|
+
end
|
|
145
|
+
if cache
|
|
146
|
+
layers << Transport::Middleware::Cache.builder(ttl: cache_ttl,
|
|
147
|
+
max_entries: cache_max_entries)
|
|
148
|
+
end
|
|
149
|
+
if throttle_interval.to_f.positive?
|
|
150
|
+
layers << Transport::Middleware::Throttle.builder(interval: throttle_interval)
|
|
151
|
+
end
|
|
152
|
+
if retry_attempts.to_i > 1
|
|
153
|
+
layers << Transport::Middleware::Retry.builder(attempts: retry_attempts,
|
|
154
|
+
backoff: retry_backoff)
|
|
155
|
+
end
|
|
156
|
+
layers
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "punycode"
|
|
5
|
+
|
|
6
|
+
module MonoVM
|
|
7
|
+
module Whois
|
|
8
|
+
# An immutable, normalised domain name.
|
|
9
|
+
#
|
|
10
|
+
# This class knows about the *name* and nothing else: how to clean up what a
|
|
11
|
+
# user pasted, how to move between Unicode and Punycode, and whether the
|
|
12
|
+
# result is a legal host name. Deciding which part of it is the TLD needs the
|
|
13
|
+
# server definitions, so that belongs to {Registry::ServerRegistry} — a name
|
|
14
|
+
# cannot know on its own whether +.co.uk+ is a suffix or two labels.
|
|
15
|
+
#
|
|
16
|
+
# name = DomainName.parse(" HTTPS://WWW.MÜNCHEN.de:8080/path?q=1 ")
|
|
17
|
+
# name.to_s # => "www.münchen.de"
|
|
18
|
+
# name.ascii # => "www.xn--mnchen-3ya.de"
|
|
19
|
+
# name.valid? # => true
|
|
20
|
+
class DomainName
|
|
21
|
+
ACE_PREFIX = "xn--"
|
|
22
|
+
MAX_LENGTH = 253
|
|
23
|
+
MAX_LABEL_LENGTH = 63
|
|
24
|
+
|
|
25
|
+
# A single legal DNS label: alphanumeric ends, hyphens allowed inside.
|
|
26
|
+
LABEL = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
|
|
27
|
+
|
|
28
|
+
attr_reader :input, :unicode
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Normalise +input+ into a name. Never raises: an unusable string yields
|
|
32
|
+
# an instance whose {#valid?} is false, which is what bulk callers want so
|
|
33
|
+
# one bad entry does not abort a batch.
|
|
34
|
+
#
|
|
35
|
+
# @param input [String]
|
|
36
|
+
# @return [DomainName]
|
|
37
|
+
def parse(input)
|
|
38
|
+
raise InvalidDomainError, input unless input.is_a?(String)
|
|
39
|
+
|
|
40
|
+
new(input)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Like {.parse} but raises when the result is not a usable host name.
|
|
44
|
+
#
|
|
45
|
+
# @raise [InvalidDomainError]
|
|
46
|
+
def parse!(input)
|
|
47
|
+
parse(input).tap do |name|
|
|
48
|
+
raise InvalidDomainError, input unless name.valid?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Everything is computed here rather than memoised on demand, because the
|
|
54
|
+
# instance is frozen: a value object that lazily fills in an ivar would raise
|
|
55
|
+
# FrozenError the first time anyone asked for it.
|
|
56
|
+
def initialize(input)
|
|
57
|
+
@input = input
|
|
58
|
+
@unicode = normalise(input).freeze
|
|
59
|
+
@labels = @unicode.split(".", -1).freeze
|
|
60
|
+
@ascii = @labels.map { |label| label_to_ascii(label) }.join(".").freeze
|
|
61
|
+
@ascii_labels = @ascii.split(".", -1).freeze
|
|
62
|
+
@valid = compute_valid?
|
|
63
|
+
freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The Unicode form — what a human reads.
|
|
67
|
+
def to_s
|
|
68
|
+
unicode
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The ASCII (Punycode) form — what goes on the wire and into URLs.
|
|
72
|
+
#
|
|
73
|
+
# Registries are queried in this form by default: Verisign answers "No match"
|
|
74
|
+
# to a UTF-8 query, which would read as availability.
|
|
75
|
+
attr_reader :ascii, :labels, :ascii_labels
|
|
76
|
+
|
|
77
|
+
# True when every label is a legal DNS label and the whole name fits in a
|
|
78
|
+
# DNS query. A single-label name like "monovm" is valid — callers append a
|
|
79
|
+
# TLD to it — so this deliberately does not require a dot.
|
|
80
|
+
def valid?
|
|
81
|
+
@valid
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# True when the name needed Punycode, i.e. it has non-ASCII labels.
|
|
85
|
+
def idn?
|
|
86
|
+
ascii != unicode
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# True when the name carries no dot, so no TLD was supplied.
|
|
90
|
+
def bare?
|
|
91
|
+
!unicode.include?(".")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build a new name by appending +suffix+ to this one.
|
|
95
|
+
#
|
|
96
|
+
# DomainName.parse("monovm").join(".com").to_s # => "monovm.com"
|
|
97
|
+
def join(suffix)
|
|
98
|
+
suffix = suffix.to_s
|
|
99
|
+
suffix = ".#{suffix}" unless suffix.start_with?(".")
|
|
100
|
+
self.class.parse("#{unicode}#{suffix}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ==(other)
|
|
104
|
+
other.is_a?(DomainName) && other.unicode == unicode
|
|
105
|
+
end
|
|
106
|
+
alias eql? ==
|
|
107
|
+
|
|
108
|
+
def hash
|
|
109
|
+
[self.class, unicode].hash
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def inspect
|
|
113
|
+
"#<#{self.class.name} #{unicode.inspect}#{" invalid" unless valid?}>"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Reduce what people actually paste — URLs, mixed case, credentials, a
|
|
119
|
+
# trailing root dot — down to a bare host name.
|
|
120
|
+
def normalise(raw)
|
|
121
|
+
text = raw.to_s.strip
|
|
122
|
+
text = text.split("://", 2).last.to_s if text.include?("://")
|
|
123
|
+
text = text.rpartition("@").last unless text.rpartition("@").last.empty?
|
|
124
|
+
|
|
125
|
+
["/", "?", "#"].each { |separator| text = text.split(separator, 2).first.to_s }
|
|
126
|
+
|
|
127
|
+
# A bare colon is always a port here; an IPv6 literal is not a domain.
|
|
128
|
+
text = text.split(":", 2).first.to_s
|
|
129
|
+
|
|
130
|
+
text = text.strip.gsub(/\A\.+|\.+\z/, "")
|
|
131
|
+
downcase(text)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Unicode-aware downcasing plus NFC, so "MÜNCHEN" and a decomposed "münchen"
|
|
135
|
+
# both reach Punycode as the same sequence of code points.
|
|
136
|
+
def downcase(text)
|
|
137
|
+
return text.downcase if text.ascii_only?
|
|
138
|
+
|
|
139
|
+
text.unicode_normalize(:nfc).downcase
|
|
140
|
+
rescue ArgumentError, Encoding::CompatibilityError
|
|
141
|
+
# Invalid byte sequences cannot be normalised; leave them for #valid? to reject.
|
|
142
|
+
text
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def label_to_ascii(label)
|
|
146
|
+
return label if label.ascii_only?
|
|
147
|
+
|
|
148
|
+
"#{ACE_PREFIX}#{Punycode.encode(label)}"
|
|
149
|
+
rescue Punycode::Error
|
|
150
|
+
# Not convertible; hand it back unchanged so #valid? rejects the name
|
|
151
|
+
# rather than this raising from a plain reader method.
|
|
152
|
+
label
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def compute_valid?
|
|
156
|
+
return false if unicode.empty?
|
|
157
|
+
|
|
158
|
+
candidate = ascii
|
|
159
|
+
return false if candidate.empty? || candidate.length > MAX_LENGTH
|
|
160
|
+
return false unless candidate.ascii_only?
|
|
161
|
+
|
|
162
|
+
ascii_labels.all? do |label|
|
|
163
|
+
!label.empty? && label.length <= MAX_LABEL_LENGTH && LABEL.match?(label)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module MonoVM
|
|
7
|
+
module Whois
|
|
8
|
+
# Where a query gets sent, and over which protocol.
|
|
9
|
+
#
|
|
10
|
+
# Server definitions describe two very different things with one string:
|
|
11
|
+
# +socket://whois.nic.uk+ is a host to open a TCP connection to, while
|
|
12
|
+
# +https://rdap.verisign.com/com/v1/domain/+ is a URL prefix. This object
|
|
13
|
+
# normalises both so {Transport::Factory} can pick a transport by asking
|
|
14
|
+
# {#kind} rather than by re-parsing strings all over the codebase.
|
|
15
|
+
class Endpoint
|
|
16
|
+
SOCKET_SCHEME = "socket"
|
|
17
|
+
DEFAULT_WHOIS_PORT = 43
|
|
18
|
+
|
|
19
|
+
# Substituted with the queried name when present, so an unusual endpoint can
|
|
20
|
+
# put the domain somewhere other than the end of the URL.
|
|
21
|
+
PLACEHOLDER = "{domain}"
|
|
22
|
+
|
|
23
|
+
attr_reader :uri, :scheme, :host, :port
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# @param uri [String] e.g. +socket://whois.nic.uk+, +socket://host:4343+,
|
|
27
|
+
# or +https://rdap.example/domain/+
|
|
28
|
+
# @return [Endpoint]
|
|
29
|
+
# @raise [DefinitionsError] when the string is not a usable endpoint
|
|
30
|
+
def parse(uri)
|
|
31
|
+
raise DefinitionsError, "endpoint is empty" if uri.nil? || uri.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
new(uri.to_s.strip)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(uri)
|
|
38
|
+
@uri = uri
|
|
39
|
+
@scheme = uri.split("://", 2).first.to_s.downcase
|
|
40
|
+
parse_target!
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# True for port 43 WHOIS.
|
|
45
|
+
def socket?
|
|
46
|
+
scheme == SOCKET_SCHEME
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True for RDAP or any other HTTP-delivered service.
|
|
50
|
+
def http?
|
|
51
|
+
%w[http https].include?(scheme)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tls?
|
|
55
|
+
scheme == "https"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A coarse label used to select a transport and to tag a {Response}.
|
|
59
|
+
def kind
|
|
60
|
+
socket? ? :whois : :rdap
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The URL to fetch for +name+. Only meaningful for HTTP endpoints.
|
|
64
|
+
#
|
|
65
|
+
# Endpoint.parse("https://rdap.example/domain/").url_for("a.com")
|
|
66
|
+
# # => "https://rdap.example/domain/a.com"
|
|
67
|
+
def url_for(name)
|
|
68
|
+
raise DefinitionsError, "#{uri} is not an HTTP endpoint" unless http?
|
|
69
|
+
return uri.sub(PLACEHOLDER, name) if uri.include?(PLACEHOLDER)
|
|
70
|
+
|
|
71
|
+
"#{uri}#{name}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_s
|
|
75
|
+
uri
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ==(other)
|
|
79
|
+
other.is_a?(Endpoint) && other.uri == uri
|
|
80
|
+
end
|
|
81
|
+
alias eql? ==
|
|
82
|
+
|
|
83
|
+
def hash
|
|
84
|
+
[self.class, uri].hash
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inspect
|
|
88
|
+
"#<#{self.class.name} #{uri}>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def parse_target!
|
|
94
|
+
if socket?
|
|
95
|
+
parse_socket_target!
|
|
96
|
+
elsif http?
|
|
97
|
+
parse_http_target!
|
|
98
|
+
else
|
|
99
|
+
raise DefinitionsError, "unsupported endpoint scheme in #{uri.inspect}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# +socket://host+ or +socket://host:port+. A trailing slash is tolerated
|
|
104
|
+
# because a few definition files carry one.
|
|
105
|
+
def parse_socket_target!
|
|
106
|
+
target = uri.split("://", 2).last.to_s.delete_suffix("/")
|
|
107
|
+
host, _, port = target.rpartition(":")
|
|
108
|
+
|
|
109
|
+
if host.empty? || !port.match?(/\A\d+\z/)
|
|
110
|
+
@host = target
|
|
111
|
+
@port = DEFAULT_WHOIS_PORT
|
|
112
|
+
else
|
|
113
|
+
@host = host
|
|
114
|
+
@port = port.to_i
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
raise DefinitionsError, "no host in endpoint #{uri.inspect}" if @host.empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_http_target!
|
|
121
|
+
parsed = URI.parse(uri.sub(PLACEHOLDER, "placeholder"))
|
|
122
|
+
raise DefinitionsError, "no host in endpoint #{uri.inspect}" if parsed.host.nil?
|
|
123
|
+
|
|
124
|
+
@host = parsed.host
|
|
125
|
+
@port = parsed.port
|
|
126
|
+
rescue URI::InvalidURIError => e
|
|
127
|
+
raise DefinitionsError, "invalid endpoint #{uri.inspect}: #{e.message}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|