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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Parser
8
+ # A registration, parsed into typed fields.
9
+ #
10
+ # A library that hands back only the raw response leaves every caller that
11
+ # wants an expiry date writing its own regexp — once per registry format.
12
+ # This is that work done once.
13
+ #
14
+ # {#fields} keeps every key/value the parser saw, so nothing is lost to the
15
+ # normalisation: if a registry emits something this class has no accessor for,
16
+ # it is still reachable.
17
+ class Record
18
+ # Values registries use to mean "withheld", mostly post-GDPR. Treated as
19
+ # absent rather than reported as a registrant literally named "REDACTED FOR
20
+ # PRIVACY", which would otherwise look like real data to a caller.
21
+ REDACTIONS = [
22
+ "redacted for privacy", "redacted", "not disclosed", "data protected",
23
+ "privacy protected", "withheld for privacy", "statutory masking enabled",
24
+ "gdpr masked", "non-public data", "not available", "n/a", "none"
25
+ ].freeze
26
+
27
+ attr_reader :domain, :registry_id, :registrar, :registrar_whois_server,
28
+ :registrar_url, :registrar_iana_id, :registrant, :statuses,
29
+ :nameservers, :created_on, :updated_on, :expires_on, :dnssec,
30
+ :contacts, :fields, :source
31
+
32
+ def initialize(domain: nil, registry_id: nil, registrar: nil, registrar_whois_server: nil,
33
+ registrar_url: nil, registrar_iana_id: nil, registrant: nil, statuses: [],
34
+ nameservers: [], created_on: nil, updated_on: nil, expires_on: nil,
35
+ dnssec: nil, contacts: {}, fields: {}, source: nil)
36
+ @domain = clean(domain)
37
+ @registry_id = clean(registry_id)
38
+ @registrar = clean(registrar)
39
+ @registrar_whois_server = clean(registrar_whois_server)&.downcase
40
+ @registrar_url = clean(registrar_url)
41
+ @registrar_iana_id = clean(registrar_iana_id)
42
+ @registrant = clean(registrant)
43
+ @statuses = normalise_list(statuses).freeze
44
+ @nameservers = normalise_nameservers(nameservers).freeze
45
+ @created_on = created_on
46
+ @updated_on = updated_on
47
+ @expires_on = expires_on
48
+ @dnssec = dnssec
49
+ @contacts = contacts.freeze
50
+ @fields = fields.freeze
51
+ @source = source
52
+ freeze
53
+ end
54
+
55
+ # The constructor's arguments, as a Hash. Lets a record be rebuilt with a few
56
+ # fields changed — which is what merging a registry record with a registrar's
57
+ # amounts to — without naming all sixteen fields at every call site.
58
+ #
59
+ # @return [Hash]
60
+ def attributes
61
+ {
62
+ domain: domain, registry_id: registry_id, registrar: registrar,
63
+ registrar_whois_server: registrar_whois_server, registrar_url: registrar_url,
64
+ registrar_iana_id: registrar_iana_id, registrant: registrant,
65
+ statuses: statuses, nameservers: nameservers, created_on: created_on,
66
+ updated_on: updated_on, expires_on: expires_on, dnssec: dnssec,
67
+ contacts: contacts, fields: fields, source: source
68
+ }
69
+ end
70
+
71
+ # True when the parser found nothing worth reporting. A response can parse
72
+ # cleanly and still be empty — a not-found reply, for instance.
73
+ def empty?
74
+ domain.nil? && registrar.nil? && nameservers.empty? && statuses.empty? &&
75
+ created_on.nil? && expires_on.nil?
76
+ end
77
+
78
+ def dnssec?
79
+ return false if dnssec.nil?
80
+
81
+ !%w[unsigned unsigned delegation no false 0].include?(dnssec.to_s.downcase.strip)
82
+ end
83
+
84
+ # True when the registry says this name is locked against transfer.
85
+ def transfer_prohibited?
86
+ statuses.any? { |status| status.downcase.include?("transferprohibited") }
87
+ end
88
+
89
+ # True for any status meaning the registration is winding down.
90
+ def expiring?
91
+ statuses.any? do |status|
92
+ normalised = status.downcase.delete(" ")
93
+ normalised.include?("redemption") || normalised.include?("pendingdelete") ||
94
+ normalised.include?("autorenewperiod")
95
+ end
96
+ end
97
+
98
+ # Days until expiry, or nil when the record carries no expiry date.
99
+ # Negative when the date has passed.
100
+ def days_until_expiry(now: Time.now)
101
+ return nil if expires_on.nil?
102
+
103
+ ((expires_on - now) / 86_400).floor
104
+ end
105
+
106
+ # Look up a raw field by any of its names, case-insensitively.
107
+ #
108
+ # record["Registry Expiry Date"]
109
+ def [](key)
110
+ wanted = key.to_s.downcase.strip
111
+ _, value = fields.find { |name, _| name.to_s.downcase.strip == wanted }
112
+ value
113
+ end
114
+
115
+ def to_h
116
+ {
117
+ domain: domain,
118
+ registry_id: registry_id,
119
+ registrar: registrar,
120
+ registrar_whois_server: registrar_whois_server,
121
+ registrar_url: registrar_url,
122
+ registrar_iana_id: registrar_iana_id,
123
+ registrant: registrant,
124
+ statuses: statuses,
125
+ nameservers: nameservers,
126
+ created_on: created_on&.iso8601,
127
+ updated_on: updated_on&.iso8601,
128
+ expires_on: expires_on&.iso8601,
129
+ dnssec: dnssec,
130
+ contacts: contacts.empty? ? nil : contacts,
131
+ source: source
132
+ }.compact
133
+ end
134
+
135
+ def inspect
136
+ "#<#{self.class.name} #{domain.inspect} registrar=#{registrar.inspect} " \
137
+ "expires=#{expires_on&.iso8601.inspect}>"
138
+ end
139
+
140
+ private
141
+
142
+ def clean(value)
143
+ text = value.to_s.strip
144
+ return nil if text.empty?
145
+ return nil if REDACTIONS.include?(text.downcase)
146
+
147
+ text
148
+ end
149
+
150
+ def normalise_list(values)
151
+ Array(values).filter_map { |value| clean(value) }.uniq
152
+ end
153
+
154
+ # Registries pad nameservers with an IP address on the same line
155
+ # (+ns1.example.com 192.0.2.1+) and disagree about case.
156
+ def normalise_nameservers(values)
157
+ Array(values).filter_map do |value|
158
+ host = clean(value)&.split(/[\s,]+/)&.first
159
+ host&.downcase&.delete_suffix(".")
160
+ end.uniq
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rdap_json"
4
+ require_relative "icann_rdd"
5
+ require_relative "key_value"
6
+ require_relative "record"
7
+
8
+ module MonoVM
9
+ module Whois
10
+ module Parser
11
+ # Picks the parser that understands a response.
12
+ #
13
+ # Ordered most specific first: RDAP JSON, then the ICANN gTLD format, then the
14
+ # generic key/value fallback that handles everything else. Each parser decides
15
+ # for itself whether it applies, so adding support for a registry with a genuinely
16
+ # odd format means writing one parser and registering it — no changes here.
17
+ #
18
+ # Named +Selector+ rather than +Registry+ on purpose: {MonoVM::Whois::Registry}
19
+ # already means "the TLD server registry", and two different Registry classes in
20
+ # one library is a trap for whoever reads it next.
21
+ class Selector
22
+ def self.default
23
+ new([RdapJson.new, IcannRdd.new, KeyValue.new])
24
+ end
25
+
26
+ attr_reader :parsers
27
+
28
+ def initialize(parsers = [])
29
+ @parsers = parsers.dup
30
+ end
31
+
32
+ # @param response [Response]
33
+ # @return [Record] an empty record when nothing understood the response,
34
+ # which is normal: a not-found reply has no registration to parse
35
+ def parse(response)
36
+ parser = parser_for(response)
37
+ return Record.new(source: nil) if parser.nil?
38
+
39
+ parser.parse(response)
40
+ rescue StandardError => e
41
+ # A malformed record must not turn a successful lookup into an exception.
42
+ # The verdict does not depend on parsing, so a failure here costs the
43
+ # caller the structured fields and nothing else.
44
+ Record.new(fields: { "parse_error" => "#{e.class}: #{e.message}" }, source: parser&.name)
45
+ end
46
+
47
+ # @return [Base, nil]
48
+ def parser_for(response)
49
+ parsers.find { |parser| parser.applicable?(response) }
50
+ end
51
+
52
+ # Add a parser ahead of the defaults.
53
+ def prepend(parser)
54
+ @parsers.unshift(parser)
55
+ self
56
+ end
57
+
58
+ def append(parser)
59
+ @parsers.push(parser)
60
+ self
61
+ end
62
+ alias << append
63
+
64
+ def names
65
+ parsers.map(&:name)
66
+ end
67
+
68
+ def inspect
69
+ "#<#{self.class.name} #{names.join(", ")}>"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonoVM
4
+ module Whois
5
+ # Locations of the data files shipped inside the gem.
6
+ module Paths
7
+ # +lib/monovm/whois/paths.rb+ -> the gem root.
8
+ ROOT = File.expand_path("../../..", __dir__)
9
+ DATA_DIR = File.join(ROOT, "data")
10
+
11
+ # Curated WHOIS server list: TLD -> port 43 host plus availability markers.
12
+ WHOIS_SERVERS = File.join(DATA_DIR, "whois_servers.json")
13
+
14
+ # Snapshot of the IANA RDAP bootstrap registry (RFC 7484).
15
+ # Refresh with +rake data:refresh_rdap+.
16
+ RDAP_BOOTSTRAP = File.join(DATA_DIR, "rdap_bootstrap.json")
17
+
18
+ # Environment variable pointing at an override file, so a deployment can
19
+ # correct a stale registry entry without waiting for a gem release.
20
+ OVERRIDE_ENV_VAR = "MONOVM_WHOIS_DEFINITIONS"
21
+
22
+ class << self
23
+ # @return [String, nil] the override file path, if one is configured
24
+ def override
25
+ path = ENV.fetch(OVERRIDE_ENV_VAR, nil)
26
+ path.nil? || path.strip.empty? ? nil : path.strip
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonoVM
4
+ module Whois
5
+ # Punycode (RFC 3492) encoder and decoder.
6
+ #
7
+ # Ruby has no Punycode or IDNA support in its standard library, and pulling in
8
+ # a gem for ~120 lines of well-specified arithmetic would cost this library its
9
+ # zero-dependency guarantee. So it lives here, transcribed from the reference
10
+ # pseudocode in RFC 3492 section 6 and checked against the test vectors in
11
+ # section 7.1.
12
+ #
13
+ # This module deals in single labels. Whole-name conversion, the +xn--+ prefix
14
+ # and validation belong to {DomainName}.
15
+ module Punycode
16
+ BASE = 36
17
+ TMIN = 1
18
+ TMAX = 26
19
+ SKEW = 38
20
+ DAMP = 700
21
+ INITIAL_BIAS = 72
22
+ INITIAL_N = 128
23
+ DELIMITER = "-"
24
+
25
+ # Highest code point that survives a round trip through the bias arithmetic
26
+ # without overflowing what a DNS label could ever hold.
27
+ MAX_CODE_POINT = 0x10FFFF
28
+
29
+ # Raised when a label is not valid Punycode. Never leaks out of
30
+ # {DomainName}, which treats an undecodable label as "leave it alone".
31
+ class Error < MonoVM::Whois::Error; end
32
+
33
+ class << self
34
+ # Encode a Unicode label as Punycode, without the +xn--+ prefix.
35
+ #
36
+ # Punycode.encode("münchen") # => "mnchen-3ya"
37
+ #
38
+ # @param input [String] one label, already normalised and downcased
39
+ # @return [String]
40
+ def encode(input)
41
+ code_points = input.codepoints
42
+ basic = code_points.select { |cp| basic?(cp) }
43
+
44
+ output = basic.pack("U*")
45
+ output += DELIMITER unless basic.empty?
46
+
47
+ n = INITIAL_N
48
+ delta = 0
49
+ bias = INITIAL_BIAS
50
+ handled = basic.length
51
+ basic_length = basic.length
52
+
53
+ while handled < code_points.length
54
+ # The next code point to deal with is the smallest one we have not
55
+ # reached yet; that ordering is what makes the encoding reversible.
56
+ m = code_points.select { |cp| cp >= n }.min
57
+ delta += (m - n) * (handled + 1)
58
+ n = m
59
+
60
+ code_points.each do |cp|
61
+ delta += 1 if cp < n
62
+ next unless cp == n
63
+
64
+ output += encode_delta(delta, bias)
65
+ bias = adapt(delta, handled + 1, handled == basic_length)
66
+ delta = 0
67
+ handled += 1
68
+ end
69
+
70
+ delta += 1
71
+ n += 1
72
+ end
73
+
74
+ output
75
+ end
76
+
77
+ # Decode a Punycode label. The +xn--+ prefix must already be stripped.
78
+ #
79
+ # Punycode.decode("mnchen-3ya") # => "münchen"
80
+ #
81
+ # @param input [String]
82
+ # @return [String]
83
+ # @raise [Error] if the input is not valid Punycode
84
+ def decode(input)
85
+ delimiter_at = input.rindex(DELIMITER)
86
+
87
+ if delimiter_at
88
+ basic = input[0...delimiter_at]
89
+ raise Error, "non-basic code point before the delimiter" unless basic.ascii_only?
90
+
91
+ output = basic.codepoints
92
+ cursor = delimiter_at + 1
93
+ else
94
+ output = []
95
+ cursor = 0
96
+ end
97
+
98
+ n = INITIAL_N
99
+ i = 0
100
+ bias = INITIAL_BIAS
101
+ digits = input.codepoints
102
+
103
+ while cursor < digits.length
104
+ old_i = i
105
+ weight = 1
106
+ k = BASE
107
+
108
+ loop do
109
+ raise Error, "truncated punycode sequence" if cursor >= digits.length
110
+
111
+ digit = digit_value(digits[cursor])
112
+ cursor += 1
113
+
114
+ i += digit * weight
115
+ raise Error, "punycode overflow" if i > MAX_CODE_POINT * (output.length + 1)
116
+
117
+ t = threshold(k, bias)
118
+ break if digit < t
119
+
120
+ weight *= (BASE - t)
121
+ k += BASE
122
+ end
123
+
124
+ bias = adapt(i - old_i, output.length + 1, old_i.zero?)
125
+ n += i / (output.length + 1)
126
+ i %= (output.length + 1)
127
+
128
+ raise Error, "punycode decoded to a basic code point" if basic?(n)
129
+ raise Error, "punycode decoded outside Unicode" if n > MAX_CODE_POINT
130
+
131
+ output.insert(i, n)
132
+ i += 1
133
+ end
134
+
135
+ output.pack("U*")
136
+ end
137
+
138
+ private
139
+
140
+ # Emit the variable-length integer for one delta, generalised
141
+ # variable-length quantity style (RFC 3492 section 3.3).
142
+ def encode_delta(delta, bias)
143
+ out = +""
144
+ q = delta
145
+ k = BASE
146
+
147
+ loop do
148
+ t = threshold(k, bias)
149
+ break if q < t
150
+
151
+ out << digit_char(t + ((q - t) % (BASE - t)))
152
+ q = (q - t) / (BASE - t)
153
+ k += BASE
154
+ end
155
+
156
+ out << digit_char(q)
157
+ end
158
+
159
+ # t is k - bias clamped to [TMIN, TMAX].
160
+ def threshold(k, bias)
161
+ t = k - bias
162
+ return TMIN if t < TMIN
163
+ return TMAX if t > TMAX
164
+
165
+ t
166
+ end
167
+
168
+ # Bias adaptation, RFC 3492 section 6.1. Keeps later deltas cheap to
169
+ # encode by tracking how large the previous ones were.
170
+ def adapt(delta, num_points, first_time)
171
+ delta = first_time ? delta / DAMP : delta / 2
172
+ delta += delta / num_points
173
+
174
+ k = 0
175
+ while delta > ((BASE - TMIN) * TMAX) / 2
176
+ delta /= (BASE - TMIN)
177
+ k += BASE
178
+ end
179
+
180
+ k + (((BASE - TMIN + 1) * delta) / (delta + SKEW))
181
+ end
182
+
183
+ def basic?(code_point)
184
+ code_point < INITIAL_N
185
+ end
186
+
187
+ # 0..25 map to a..z, 26..35 to 0..9.
188
+ def digit_char(digit)
189
+ raise Error, "digit out of range: #{digit}" unless digit.between?(0, BASE - 1)
190
+
191
+ (digit < 26 ? digit + 0x61 : digit - 26 + 0x30).chr
192
+ end
193
+
194
+ def digit_value(code_point)
195
+ case code_point
196
+ when 0x41..0x5A then code_point - 0x41 # A-Z
197
+ when 0x61..0x7A then code_point - 0x61 # a-z
198
+ when 0x30..0x39 then code_point - 0x30 + 26 # 0-9
199
+ else
200
+ raise Error, "not a punycode digit: #{code_point}"
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../endpoint"
4
+ require_relative "../errors"
5
+
6
+ module MonoVM
7
+ module Whois
8
+ module Referral
9
+ # Chases a thin registry's pointer to the registrar's own WHOIS server.
10
+ #
11
+ # Verisign and the other thin registries hold almost nothing: query +.com+ and
12
+ # the answer is a domain name, a status, nameservers, and a +Registrar WHOIS
13
+ # Server+ line pointing at whoever sold it. The registrant, the contact details
14
+ # and often the accurate expiry date are only on that second server. A client
15
+ # that stops at the first answer reports the thin record as the whole truth.
16
+ #
17
+ # One deliberate constraint: a referral enriches the *record*, never the
18
+ # verdict. The registry is authoritative about whether a name exists, and a
19
+ # registrar's server that is down, rate-limiting or simply wrong must not be
20
+ # able to turn a registered domain into an available one.
21
+ class Follower
22
+ # Registrars that answer but say nothing useful, or point in a circle.
23
+ # Following these costs a round trip and a rate-limit slot for no data.
24
+ SKIP_HOSTS = [
25
+ "whois.iana.org",
26
+ "whois.markmonitor.com",
27
+ "not.applicable",
28
+ "none"
29
+ ].freeze
30
+
31
+ attr_reader :transport_factory, :max_hops
32
+
33
+ # @param transport_factory [Transport::Factory]
34
+ # @param max_hops [Integer] referral hops to follow; 1 is almost always right
35
+ def initialize(transport_factory:, max_hops: 1)
36
+ @transport_factory = transport_factory
37
+ @max_hops = max_hops
38
+ end
39
+
40
+ # @param response [Response] the registry's answer
41
+ # @param record [Parser::Record] parsed from that answer
42
+ # @param query [String] the name being looked up, in wire form
43
+ # @return [Response, nil] the registrar's answer, or nil when there is no
44
+ # referral to follow or following it failed
45
+ def follow(response:, record:, query:)
46
+ return nil unless max_hops.positive?
47
+ return nil unless response.whois?
48
+
49
+ host = referral_host(record, response)
50
+ return nil if host.nil?
51
+
52
+ fetch(Endpoint.parse("socket://#{host}"), query)
53
+ rescue DefinitionsError
54
+ # The referral was not a usable host name; keep the registry's answer.
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ def referral_host(record, response)
61
+ host = record.registrar_whois_server
62
+ host = host_from_text(response) if host.nil?
63
+ return nil if host.nil?
64
+
65
+ host = host.strip.downcase.sub(%r{\Ar?whois://}, "").split("/").first.to_s
66
+ return nil if host.empty? || SKIP_HOSTS.include?(host)
67
+ # A server that refers to itself would loop.
68
+ return nil if host == response.endpoint.host.downcase
69
+ return nil unless host.include?(".")
70
+
71
+ host
72
+ end
73
+
74
+ # Some registries emit the referral without the exact key the parser knows.
75
+ def host_from_text(response)
76
+ match = response.text.match(/^\s*(?:Registrar\s+)?WHOIS\s+Server\s*:\s*(\S+)/i)
77
+ match && match[1]
78
+ end
79
+
80
+ def fetch(endpoint, query)
81
+ transport_factory.for(endpoint).fetch(query: query, endpoint: endpoint)
82
+ rescue Error
83
+ # A failed referral is not an error for the caller: the registry already
84
+ # answered the question that matters.
85
+ nil
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../endpoint"
4
+
5
+ module MonoVM
6
+ module Whois
7
+ module Registry
8
+ # Everything known about how to look up one TLD.
9
+ #
10
+ # A definition is assembled from several sources: the bundled server list
11
+ # supplies the port 43 host and the registry's "available" marker, while the
12
+ # IANA bootstrap supplies the RDAP base URL. {#merge} is what lets those
13
+ # arrive independently and still end up on one object.
14
+ class Definition
15
+ attr_reader :tld, :whois_endpoint, :rdap_endpoint, :available_match,
16
+ :premium_match, :comment, :sources
17
+
18
+ # @param tld [String] with the leading dot, e.g. +".co.uk"+
19
+ # @param whois_endpoint [Endpoint, nil] port 43 endpoint
20
+ # @param rdap_endpoint [Endpoint, nil] RDAP base URL endpoint
21
+ # @param available_match [String, nil] literal the registry emits for an
22
+ # unregistered name; a fast pre-check, not the only signal
23
+ # @param premium_match [String, nil] literal marking a premium/reserved name
24
+ # @param available_when_empty [Boolean] registries that answer an
25
+ # unregistered name with nothing but their banner. Opt-in per TLD, because
26
+ # inferring availability from silence is unsound in general.
27
+ # @param comment [String, nil] why this entry looks the way it does
28
+ # @param sources [Array<String>] which sources contributed
29
+ def initialize(tld:, whois_endpoint: nil, rdap_endpoint: nil, available_match: nil,
30
+ premium_match: nil, available_when_empty: false, comment: nil,
31
+ sources: [])
32
+ @tld = tld
33
+ @whois_endpoint = whois_endpoint
34
+ @rdap_endpoint = rdap_endpoint
35
+ @available_match = presence(available_match)
36
+ @premium_match = presence(premium_match)
37
+ @available_when_empty = available_when_empty
38
+ @comment = presence(comment)
39
+ @sources = sources.freeze
40
+ freeze
41
+ end
42
+
43
+ def available_when_empty?
44
+ @available_when_empty
45
+ end
46
+
47
+ def rdap?
48
+ !rdap_endpoint.nil?
49
+ end
50
+
51
+ def whois?
52
+ !whois_endpoint.nil?
53
+ end
54
+
55
+ # Endpoints to try, best first.
56
+ #
57
+ # RDAP goes first when available: its answer is structured JSON with an
58
+ # explicit object class or an explicit +errorCode+, so availability is read
59
+ # rather than guessed. Port 43 free text is the fallback.
60
+ #
61
+ # @param prefer [Symbol] +:rdap+ or +:whois+
62
+ # @return [Array<Endpoint>]
63
+ def endpoints(prefer: :rdap)
64
+ ordered = prefer == :whois ? [whois_endpoint, rdap_endpoint] : [rdap_endpoint, whois_endpoint]
65
+ ordered.compact
66
+ end
67
+
68
+ def usable?
69
+ rdap? || whois?
70
+ end
71
+
72
+ # Combine with another definition for the same TLD. Values from +other+ win
73
+ # where it has them, so later sources override earlier ones — that is how a
74
+ # user's override file beats the bundled list.
75
+ #
76
+ # @param other [Definition]
77
+ # @return [Definition]
78
+ def merge(other)
79
+ return self if other.nil?
80
+
81
+ self.class.new(
82
+ tld: tld,
83
+ whois_endpoint: other.whois_endpoint || whois_endpoint,
84
+ rdap_endpoint: other.rdap_endpoint || rdap_endpoint,
85
+ available_match: other.available_match || available_match,
86
+ premium_match: other.premium_match || premium_match,
87
+ available_when_empty: other.available_when_empty? || available_when_empty?,
88
+ comment: other.comment || comment,
89
+ sources: (sources + other.sources).uniq
90
+ )
91
+ end
92
+
93
+ def to_h
94
+ {
95
+ tld: tld,
96
+ whois: whois_endpoint&.to_s,
97
+ rdap: rdap_endpoint&.to_s,
98
+ available_match: available_match,
99
+ premium_match: premium_match,
100
+ available_when_empty: available_when_empty?,
101
+ sources: sources
102
+ }.compact
103
+ end
104
+
105
+ def inspect
106
+ "#<#{self.class.name} #{tld} whois=#{whois_endpoint&.host || "-"} " \
107
+ "rdap=#{rdap_endpoint ? "yes" : "no"}>"
108
+ end
109
+
110
+ private
111
+
112
+ def presence(value)
113
+ text = value.to_s.strip
114
+ text.empty? ? nil : text.freeze
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end