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,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "verdict"
|
|
4
|
+
require_relative "patterns"
|
|
5
|
+
|
|
6
|
+
module MonoVM
|
|
7
|
+
module Whois
|
|
8
|
+
module Availability
|
|
9
|
+
# One link in the detection chain.
|
|
10
|
+
#
|
|
11
|
+
# A rule answers a single question about a response and either returns a
|
|
12
|
+
# {Verdict} — "I recognise this, here is the answer" — or +nil+, meaning
|
|
13
|
+
# "not mine, ask the next one". That is the whole contract, and it is what
|
|
14
|
+
# makes the chain extensible: a registry that invents new wording needs one
|
|
15
|
+
# more small object, not a change to {Analyzer}.
|
|
16
|
+
#
|
|
17
|
+
# class MyRule < Availability::Rule
|
|
18
|
+
# def call(context)
|
|
19
|
+
# return nil unless context.lower.include?("my special wording")
|
|
20
|
+
#
|
|
21
|
+
# registered(reason: "registry said so")
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# MonoVM::Whois.configure { |c| c.rules.prepend(MyRule.new) }
|
|
26
|
+
class Rule
|
|
27
|
+
# Identifier used in {Verdict#rule} and in the analyzer's trace.
|
|
28
|
+
# +ServerRefusal+ becomes +"server_refusal"+.
|
|
29
|
+
#
|
|
30
|
+
# An anonymous class has no +Module#name+, so a rule defined inline — which is
|
|
31
|
+
# exactly how a host application tries one out — falls back to its inspect
|
|
32
|
+
# form rather than crashing.
|
|
33
|
+
def name
|
|
34
|
+
@name ||= begin
|
|
35
|
+
base = self.class.name.to_s.split("::").last
|
|
36
|
+
base = self.class.inspect if base.nil? || base.empty?
|
|
37
|
+
base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param context [Context]
|
|
42
|
+
# @return [Verdict, nil]
|
|
43
|
+
def call(context)
|
|
44
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
%i[available registered premium unknown].each do |status|
|
|
50
|
+
define_method(status) do |reason: nil, evidence: nil|
|
|
51
|
+
Verdict.public_send(status, rule: name, reason: reason, evidence: evidence)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
require_relative "rules/server_refusal"
|
|
5
|
+
require_relative "rules/wrong_registry"
|
|
6
|
+
require_relative "rules/rdap_object"
|
|
7
|
+
require_relative "rules/premium_name"
|
|
8
|
+
require_relative "rules/explicit_unavailability"
|
|
9
|
+
require_relative "rules/registration_fields"
|
|
10
|
+
require_relative "rules/registry_marker"
|
|
11
|
+
require_relative "rules/availability_keywords"
|
|
12
|
+
require_relative "rules/no_match"
|
|
13
|
+
require_relative "rules/tld_specific"
|
|
14
|
+
require_relative "rules/status_field"
|
|
15
|
+
require_relative "rules/recordless"
|
|
16
|
+
|
|
17
|
+
module MonoVM
|
|
18
|
+
module Whois
|
|
19
|
+
module Availability
|
|
20
|
+
# The ordered chain of detection rules.
|
|
21
|
+
#
|
|
22
|
+
# Order is the whole design. Two invariants hold it together:
|
|
23
|
+
#
|
|
24
|
+
# 1. Rules that recognise a *non-answer* run first, so nothing that reaches the
|
|
25
|
+
# permissive rules could have been a refusal or a wrong-server reply.
|
|
26
|
+
# 2. Every rule that concludes +:registered+ runs before every rule that
|
|
27
|
+
# concludes +:available+, so when two signals conflict the safe one wins.
|
|
28
|
+
# Selling a registered domain is a far worse failure than telling a customer
|
|
29
|
+
# to check again.
|
|
30
|
+
#
|
|
31
|
+
# The set is mutable so a host application can add a rule for a registry that
|
|
32
|
+
# invents new wording, without forking the gem or waiting for a release.
|
|
33
|
+
class RuleSet
|
|
34
|
+
include Enumerable
|
|
35
|
+
|
|
36
|
+
# The order shipped with the gem.
|
|
37
|
+
def self.default
|
|
38
|
+
new([
|
|
39
|
+
Rules::ServerRefusal.new, # non-answers first
|
|
40
|
+
Rules::WrongRegistry.new,
|
|
41
|
+
Rules::RdapObject.new, # structured, authoritative
|
|
42
|
+
Rules::PremiumName.new, # premium is not available
|
|
43
|
+
Rules::ExplicitUnavailability.new, # then everything meaning "registered"
|
|
44
|
+
Rules::RegistrationFields.new,
|
|
45
|
+
Rules::RegistryMarker.new, # then everything meaning "available"
|
|
46
|
+
Rules::AvailabilityKeywords.new,
|
|
47
|
+
Rules::NoMatch.new,
|
|
48
|
+
Rules::TldSpecific.new,
|
|
49
|
+
Rules::StatusField.new,
|
|
50
|
+
Rules::Recordless.new # opt-in per TLD
|
|
51
|
+
])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(rules = [])
|
|
55
|
+
@rules = rules.dup
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def each(&)
|
|
59
|
+
@rules.each(&)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_a
|
|
63
|
+
@rules.dup
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def size
|
|
67
|
+
@rules.size
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def names
|
|
71
|
+
@rules.map(&:name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Add a rule at the very front, ahead of even the refusal checks.
|
|
75
|
+
def prepend(rule)
|
|
76
|
+
@rules.unshift(rule)
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Add a rule at the very end, after the shipped fallbacks.
|
|
81
|
+
def append(rule)
|
|
82
|
+
@rules.push(rule)
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
alias << append
|
|
86
|
+
|
|
87
|
+
# Insert +rule+ immediately before the named rule, which is usually what you
|
|
88
|
+
# want: a registry-specific check belongs next to the generic one it refines.
|
|
89
|
+
#
|
|
90
|
+
# @param name [String, Symbol] an existing rule's {Rule#name}
|
|
91
|
+
# @raise [ArgumentError] when no rule has that name
|
|
92
|
+
def insert_before(name, rule)
|
|
93
|
+
@rules.insert(index_of!(name), rule)
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def insert_after(name, rule)
|
|
98
|
+
@rules.insert(index_of!(name) + 1, rule)
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Drop a shipped rule, for a deployment that disagrees with one of them.
|
|
103
|
+
def remove(name)
|
|
104
|
+
@rules.delete_at(index_of!(name))
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Swap a rule's implementation, keeping its position.
|
|
109
|
+
def replace(name, rule)
|
|
110
|
+
@rules[index_of!(name)] = rule
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def include?(name)
|
|
115
|
+
@rules.any? { |rule| rule.name == name.to_s }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def dup
|
|
119
|
+
self.class.new(@rules)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def inspect
|
|
123
|
+
"#<#{self.class.name} #{names.join(" -> ")}>"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def index_of!(name)
|
|
129
|
+
index = @rules.index { |rule| rule.name == name.to_s }
|
|
130
|
+
raise ArgumentError, "no rule named #{name.inspect}; have #{names.join(", ")}" if index.nil?
|
|
131
|
+
|
|
132
|
+
index
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# Plain-language phrases that state a name is unregistered.
|
|
10
|
+
#
|
|
11
|
+
# The list spans several languages, because ccTLD registries answer in their
|
|
12
|
+
# own: +no se encontro el objeto+, +nicht vergeben+, +is vrij+. Matching is a
|
|
13
|
+
# substring search over the commentary-stripped response — the stripping is
|
|
14
|
+
# what keeps a phrase like "not found" in a registry's legal preamble from
|
|
15
|
+
# being read as an answer.
|
|
16
|
+
class AvailabilityKeywords < Rule
|
|
17
|
+
def call(context)
|
|
18
|
+
return nil if context.empty?
|
|
19
|
+
|
|
20
|
+
keyword = context.find_keyword(Patterns::AVAILABILITY_KEYWORDS)
|
|
21
|
+
return nil if keyword.nil?
|
|
22
|
+
|
|
23
|
+
available(
|
|
24
|
+
reason: "the response states the name is not registered",
|
|
25
|
+
evidence: keyword
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# The response states that the name exists.
|
|
10
|
+
#
|
|
11
|
+
# Per-TLD wording is consulted first, because some registries use words that
|
|
12
|
+
# would be ambiguous elsewhere: DENIC's +Status: invalid+ means the name
|
|
13
|
+
# cannot be registered as spelled, and Nominet answers a registered +.uk+
|
|
14
|
+
# name with nothing more definitive than "Registered".
|
|
15
|
+
#
|
|
16
|
+
# Matching happens against the response with commentary stripped. Registries
|
|
17
|
+
# put conditional prose in their preamble ("if the domain is registered,
|
|
18
|
+
# contact the registrar"), and matching that would mark a free name as taken.
|
|
19
|
+
class ExplicitUnavailability < Rule
|
|
20
|
+
def call(context)
|
|
21
|
+
return nil if context.empty?
|
|
22
|
+
|
|
23
|
+
tld_patterns = Patterns::TLD_UNAVAILABILITY[context.tld]
|
|
24
|
+
if tld_patterns
|
|
25
|
+
matched = context.find(tld_patterns, source: :significant)
|
|
26
|
+
if matched
|
|
27
|
+
return registered(
|
|
28
|
+
reason: "#{context.tld} registries report a registration this way",
|
|
29
|
+
evidence: matched
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
matched = context.find(Patterns::UNAVAILABILITY, source: :significant)
|
|
35
|
+
return nil if matched.nil?
|
|
36
|
+
|
|
37
|
+
registered(reason: "the response states the name is registered", evidence: matched)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# The regexp form of the not-found phrases.
|
|
10
|
+
#
|
|
11
|
+
# Overlaps {AvailabilityKeywords} on purpose. That rule does a fast substring
|
|
12
|
+
# scan, which misses a registry that writes +No match+ with two spaces or
|
|
13
|
+
# +not\tfound+ with a tab; these patterns are whitespace-tolerant and catch
|
|
14
|
+
# the rest. Cheap check first, thorough check second.
|
|
15
|
+
class NoMatch < Rule
|
|
16
|
+
def call(context)
|
|
17
|
+
return nil if context.empty?
|
|
18
|
+
|
|
19
|
+
matched = context.find(Patterns::NO_MATCH, source: :significant)
|
|
20
|
+
return nil if matched.nil?
|
|
21
|
+
|
|
22
|
+
available(
|
|
23
|
+
reason: "the response matched a not-found pattern",
|
|
24
|
+
evidence: matched
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# The registry flagged the name as premium, reserved or otherwise withheld.
|
|
10
|
+
#
|
|
11
|
+
# A premium name is unregistered but not obtainable at standard price, and a
|
|
12
|
+
# reserved one is not obtainable at all. Both are distinct from "available",
|
|
13
|
+
# and a registrar showing an add-to-cart button for either has a support
|
|
14
|
+
# ticket coming.
|
|
15
|
+
#
|
|
16
|
+
# The marker comes from the TLD definition, since the wording is registry
|
|
17
|
+
# specific. This runs before the availability rules because those registries
|
|
18
|
+
# tend to phrase a premium answer as a near-miss of "not found".
|
|
19
|
+
class PremiumName < Rule
|
|
20
|
+
def call(context)
|
|
21
|
+
marker = context.definition&.premium_match
|
|
22
|
+
return nil if marker.nil? || context.empty?
|
|
23
|
+
|
|
24
|
+
return nil unless context.lower.include?(marker.downcase)
|
|
25
|
+
|
|
26
|
+
premium(
|
|
27
|
+
reason: "the registry marked this name as premium or reserved",
|
|
28
|
+
evidence: marker
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# Reads an RDAP response, which says outright whether the object exists.
|
|
10
|
+
#
|
|
11
|
+
# This is why the library prefers RDAP. Every other rule in the chain infers
|
|
12
|
+
# a verdict from prose that varies by registry, by year and sometimes by
|
|
13
|
+
# query. RDAP does not need inference: a registered domain is a JSON object
|
|
14
|
+
# with an +objectClassName+ of +"domain"+, and an unregistered one is an
|
|
15
|
+
# error document with +errorCode+ 404 (RFC 7480 §5.3, RFC 9083 §6). Both
|
|
16
|
+
# answers are definitive, so this rule sits above all the pattern matching.
|
|
17
|
+
class RdapObject < Rule
|
|
18
|
+
def call(context)
|
|
19
|
+
return nil unless context.rdap?
|
|
20
|
+
|
|
21
|
+
document = context.json
|
|
22
|
+
|
|
23
|
+
# An HTTP 404 is RDAP's "no such domain", whether or not it carried a body.
|
|
24
|
+
if document.nil?
|
|
25
|
+
return nil unless Patterns::RDAP_NOT_FOUND_CODES.include?(context.http_status)
|
|
26
|
+
|
|
27
|
+
return available(
|
|
28
|
+
reason: "RDAP answered HTTP 404: no such domain",
|
|
29
|
+
evidence: "HTTP #{context.http_status}"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
verdict_for_error(document, context) || verdict_for_object(document)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def verdict_for_error(document, _context)
|
|
39
|
+
code = document["errorCode"]
|
|
40
|
+
return nil if code.nil?
|
|
41
|
+
|
|
42
|
+
code = code.to_i
|
|
43
|
+
|
|
44
|
+
if Patterns::RDAP_NOT_FOUND_CODES.include?(code)
|
|
45
|
+
return available(
|
|
46
|
+
reason: "RDAP errorCode 404: no such domain",
|
|
47
|
+
evidence: error_evidence(document, code)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Any other error code is the server declining, not an answer: 429 for
|
|
52
|
+
# rate limiting, 403 for a blocked client, 400 for a query it disliked.
|
|
53
|
+
unknown(
|
|
54
|
+
reason: "RDAP returned errorCode #{code}",
|
|
55
|
+
evidence: error_evidence(document, code)
|
|
56
|
+
)
|
|
57
|
+
rescue TypeError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def verdict_for_object(document)
|
|
62
|
+
return nil unless describes_domain?(document)
|
|
63
|
+
|
|
64
|
+
present = Patterns::RDAP_REGISTRATION_KEYS.count { |key| document.key?(key) }
|
|
65
|
+
|
|
66
|
+
# A conformant domain object always carries an ldhName plus at least
|
|
67
|
+
# something about its state; requiring two keys avoids treating a bare
|
|
68
|
+
# help or notice document as a registration.
|
|
69
|
+
return nil unless present >= 2
|
|
70
|
+
|
|
71
|
+
registered(
|
|
72
|
+
reason: "RDAP returned a domain object",
|
|
73
|
+
evidence: document["ldhName"] || document["handle"]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def describes_domain?(document)
|
|
78
|
+
object_class = document["objectClassName"].to_s.downcase
|
|
79
|
+
return true if object_class == "domain"
|
|
80
|
+
|
|
81
|
+
# Some registries omit objectClassName but are unmistakably describing a
|
|
82
|
+
# registration; rdapConformance plus an ldhName is enough.
|
|
83
|
+
document.key?("ldhName") && document.key?("rdapConformance")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def error_evidence(document, code)
|
|
87
|
+
title = document["title"] || Array(document["description"]).first
|
|
88
|
+
[code, title].compact.join(" ")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# Last resort: the server answered with its banner and nothing else.
|
|
10
|
+
#
|
|
11
|
+
# A few registries — NIC Monaco among them — reply to an unregistered name
|
|
12
|
+
# with no marker at all, just their header. For those, silence genuinely is
|
|
13
|
+
# the signal, and no other rule can help.
|
|
14
|
+
#
|
|
15
|
+
# Inferring availability from absence is unsound in general, so this rule is
|
|
16
|
+
# off unless a TLD definition sets +available_when_empty+, and even then it
|
|
17
|
+
# refuses to answer if the response looks like anything other than a clean
|
|
18
|
+
# empty reply: no registration fields, no refusal, no error or restriction
|
|
19
|
+
# notice. Opt-in per TLD rather than a global fallback is the difference
|
|
20
|
+
# between handling one registry's quirk and reporting every unreadable
|
|
21
|
+
# response as free to register.
|
|
22
|
+
class Recordless < Rule
|
|
23
|
+
# Below two registration fields the response is not a record. At two or more
|
|
24
|
+
# it is, however terse.
|
|
25
|
+
MAX_FIELDS = 2
|
|
26
|
+
|
|
27
|
+
def call(context)
|
|
28
|
+
return nil unless context.definition&.available_when_empty?
|
|
29
|
+
return nil if context.empty?
|
|
30
|
+
return nil if context.match?(Patterns::ERROR_OR_RESTRICTION, source: :significant)
|
|
31
|
+
|
|
32
|
+
fields = context.count(Patterns::REGISTRATION_FIELDS, source: :significant)
|
|
33
|
+
return nil if fields >= MAX_FIELDS
|
|
34
|
+
|
|
35
|
+
available(
|
|
36
|
+
reason: "#{context.tld} answers unregistered names with no record, " \
|
|
37
|
+
"and this response carries none",
|
|
38
|
+
evidence: context.preview(80)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# The response is a record: it carries the fields only a registration has.
|
|
10
|
+
#
|
|
11
|
+
# Useful for the many registries that never say "registered" in so many
|
|
12
|
+
# words — they simply return the record, and the record's existence is the
|
|
13
|
+
# answer. Counting fields rather than looking for one is deliberate: a
|
|
14
|
+
# registrar name might appear in a preamble, but a registrar *and* a creation
|
|
15
|
+
# date *and* nameservers together are a registration.
|
|
16
|
+
class RegistrationFields < Rule
|
|
17
|
+
# Three independent fields. Two is reachable by a chatty error notice; four
|
|
18
|
+
# loses the GDPR-redacted records that only keep domain, registrar and
|
|
19
|
+
# status.
|
|
20
|
+
THRESHOLD = 3
|
|
21
|
+
|
|
22
|
+
def call(context)
|
|
23
|
+
return nil if context.empty?
|
|
24
|
+
|
|
25
|
+
found = context.count(Patterns::REGISTRATION_INDICATORS, source: :significant)
|
|
26
|
+
return nil if found < THRESHOLD
|
|
27
|
+
|
|
28
|
+
registered(
|
|
29
|
+
reason: "the response contains #{found} registration fields",
|
|
30
|
+
evidence: context.find(Patterns::REGISTRATION_INDICATORS, source: :significant)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# The exact string this registry emits for an unregistered name, taken from
|
|
10
|
+
# the TLD definition.
|
|
11
|
+
#
|
|
12
|
+
# This is the curated, high-confidence signal — +"No match for"+ for Verisign,
|
|
13
|
+
# +"Domain not found"+ for PIR — and the obvious design would check it first,
|
|
14
|
+
# before anything else.
|
|
15
|
+
#
|
|
16
|
+
# Here it runs *after* the rules that establish a registration, and that
|
|
17
|
+
# reordering fixes a real failure mode: when a TLD is mapped to the wrong
|
|
18
|
+
# server, or a registry starts returning a record that happens to contain the
|
|
19
|
+
# marker text, checking the marker first turns a registered domain into an
|
|
20
|
+
# available one. Letting a genuine record win first costs nothing, because a
|
|
21
|
+
# response cannot both be a record and be the registry's not-found message.
|
|
22
|
+
class RegistryMarker < Rule
|
|
23
|
+
def call(context)
|
|
24
|
+
marker = context.definition&.available_match
|
|
25
|
+
return nil if marker.nil? || context.empty?
|
|
26
|
+
|
|
27
|
+
return nil unless context.significant_lower.include?(marker.downcase)
|
|
28
|
+
|
|
29
|
+
available(
|
|
30
|
+
reason: "matched the not-found marker configured for #{context.tld}",
|
|
31
|
+
evidence: marker
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# The server answered, but declined to say anything about the domain.
|
|
10
|
+
#
|
|
11
|
+
# First in the chain, and that position is the point. Rate-limit notices,
|
|
12
|
+
# "your client is blocked" messages and "port 43 is retired, use RDAP"
|
|
13
|
+
# banners contain none of the wording that means "registered", so every
|
|
14
|
+
# later rule passes on them and the response eventually reaches whichever
|
|
15
|
+
# heuristic is loosest. In a permissive detector that heuristic answers
|
|
16
|
+
# "available", which means a rate-limited registry reports its entire zone
|
|
17
|
+
# as free to register.
|
|
18
|
+
#
|
|
19
|
+
# Deciding this first, and deciding it +:unknown+, is what makes the rest of
|
|
20
|
+
# the chain safe to be permissive.
|
|
21
|
+
class ServerRefusal < Rule
|
|
22
|
+
def call(context)
|
|
23
|
+
return nil if context.empty?
|
|
24
|
+
|
|
25
|
+
# An RDAP document says "I refuse" structurally — an HTTP 401/403/429,
|
|
26
|
+
# which the transport turns into an error, or an +errorCode+, which
|
|
27
|
+
# {RdapObject} reads. Its prose must not be scanned for refusal wording,
|
|
28
|
+
# because registries embed rate-limit *policy* in the Terms of Service
|
|
29
|
+
# notice attached to every answer: PIR's .org responses explain that a
|
|
30
|
+
# client sending "too many queries" will be throttled, and matching that
|
|
31
|
+
# reported every unregistered .org name as unknown.
|
|
32
|
+
return nil unless context.json.nil?
|
|
33
|
+
|
|
34
|
+
matched = context.find(Patterns::REFUSAL)
|
|
35
|
+
return nil if matched.nil?
|
|
36
|
+
|
|
37
|
+
unknown(
|
|
38
|
+
reason: "the server refused to answer the query",
|
|
39
|
+
evidence: matched
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# An explicit status field stating the name is free.
|
|
10
|
+
#
|
|
11
|
+
# Some detectors pair this check with a second inference: "fewer than two
|
|
12
|
+
# registration fields present" also counts as availability. That inference is
|
|
13
|
+
# unsound and is deliberately not reproduced. Every response that is not a
|
|
14
|
+
# record has fewer than two registration fields — a rate-limit notice, a
|
|
15
|
+
# blocked-client message, a legal preamble, a truncated read — so it converts
|
|
16
|
+
# any non-answer into "free to register".
|
|
17
|
+
#
|
|
18
|
+
# Genuine availability always carries a positive signal, and the keyword,
|
|
19
|
+
# not-found and per-TLD rules already look for it. The one case where silence
|
|
20
|
+
# really is the only signal is handled by {Recordless}, which requires an
|
|
21
|
+
# explicit per-TLD opt-in.
|
|
22
|
+
class StatusField < Rule
|
|
23
|
+
def call(context)
|
|
24
|
+
return nil if context.empty?
|
|
25
|
+
|
|
26
|
+
# An error or restriction notice can contain a status line while still
|
|
27
|
+
# describing something that exists.
|
|
28
|
+
return nil if context.match?(Patterns::ERROR_OR_RESTRICTION, source: :significant)
|
|
29
|
+
|
|
30
|
+
matched = context.find(Patterns::STATUS_AVAILABLE, source: :significant)
|
|
31
|
+
return nil if matched.nil?
|
|
32
|
+
|
|
33
|
+
available(
|
|
34
|
+
reason: "an explicit status field reports the name as available",
|
|
35
|
+
evidence: matched
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
module Rules
|
|
9
|
+
# Per-TLD availability wording.
|
|
10
|
+
#
|
|
11
|
+
# Runs late because several entries are single words — +.it+ answers a free
|
|
12
|
+
# name with little more than "available", +.ch+ with "we do not have an entry
|
|
13
|
+
# in our database matching your query". As a general rule those would be far
|
|
14
|
+
# too loose; scoped to one registry's output format they are reliable.
|
|
15
|
+
#
|
|
16
|
+
# By the time the chain gets here, every rule that could establish a
|
|
17
|
+
# registration has already declined, so a loose availability pattern can only
|
|
18
|
+
# fire on a response that carried no registration signal at all.
|
|
19
|
+
class TldSpecific < Rule
|
|
20
|
+
def call(context)
|
|
21
|
+
return nil if context.empty? || context.tld.nil?
|
|
22
|
+
|
|
23
|
+
patterns = Patterns::TLD_AVAILABILITY[context.tld]
|
|
24
|
+
return nil if patterns.nil?
|
|
25
|
+
|
|
26
|
+
matched = context.find(patterns, source: :significant)
|
|
27
|
+
return nil if matched.nil?
|
|
28
|
+
|
|
29
|
+
available(
|
|
30
|
+
reason: "matched an availability pattern specific to #{context.tld}",
|
|
31
|
+
evidence: matched
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|