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
data/exe/monovm-whois
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "context"
|
|
4
|
+
require_relative "rule_set"
|
|
5
|
+
require_relative "verdict"
|
|
6
|
+
|
|
7
|
+
module MonoVM
|
|
8
|
+
module Whois
|
|
9
|
+
module Availability
|
|
10
|
+
# Runs a response through the rule chain and returns the first verdict.
|
|
11
|
+
#
|
|
12
|
+
# The analyzer holds no detection logic of its own — it walks the {RuleSet},
|
|
13
|
+
# stops at the first rule that answers, and records what every rule said on the
|
|
14
|
+
# way. That trace is what turns "why does this say available?" from an
|
|
15
|
+
# afternoon of guessing into one call to {#explain}.
|
|
16
|
+
#
|
|
17
|
+
# When no rule answers, the result is +:unknown+. That is the other half of the
|
|
18
|
+
# correctness argument: the default is "I do not know", not "it is free".
|
|
19
|
+
class Analyzer
|
|
20
|
+
attr_reader :rules
|
|
21
|
+
|
|
22
|
+
def initialize(rules: nil)
|
|
23
|
+
@rules = rules || RuleSet.default
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param response [Response, nil]
|
|
27
|
+
# @param tld [String, nil]
|
|
28
|
+
# @param definition [Registry::Definition, nil]
|
|
29
|
+
# @return [Verdict]
|
|
30
|
+
def analyze(response:, tld: nil, definition: nil)
|
|
31
|
+
context = Context.new(response: response, tld: tld, definition: definition)
|
|
32
|
+
run(context)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Analyse and return the verdict together with every rule's outcome, for
|
|
36
|
+
# debugging a surprising classification.
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash]
|
|
39
|
+
def explain(response:, tld: nil, definition: nil)
|
|
40
|
+
context = Context.new(response: response, tld: tld, definition: definition)
|
|
41
|
+
verdict = run(context)
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
status: verdict.status,
|
|
45
|
+
decided_by: verdict.rule,
|
|
46
|
+
reason: verdict.reason,
|
|
47
|
+
evidence: verdict.evidence,
|
|
48
|
+
trace: verdict.trace,
|
|
49
|
+
tld: tld,
|
|
50
|
+
response_length: context.length,
|
|
51
|
+
response_preview: context.preview
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def run(context)
|
|
58
|
+
trace = []
|
|
59
|
+
|
|
60
|
+
rules.each do |rule|
|
|
61
|
+
begin
|
|
62
|
+
verdict = rule.call(context)
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
# A rule that raises must not take the lookup down with it, and must
|
|
65
|
+
# not be treated as a match either. Recording the failure keeps a
|
|
66
|
+
# broken custom rule visible rather than silently skipped.
|
|
67
|
+
trace << { rule: rule.name, matched: false, error: "#{e.class}: #{e.message}" }
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if verdict.nil?
|
|
72
|
+
trace << { rule: rule.name, matched: false }
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
trace << {
|
|
77
|
+
rule: rule.name,
|
|
78
|
+
matched: true,
|
|
79
|
+
status: verdict.status,
|
|
80
|
+
evidence: verdict.evidence
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return verdict.with_trace(trace)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Verdict.unknown(reason: "no rule recognised this response", trace: trace)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "patterns"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Availability
|
|
8
|
+
# Everything a rule needs to reach a verdict, prepared once.
|
|
9
|
+
#
|
|
10
|
+
# Ten rules run over the same response, and most of them want the same derived
|
|
11
|
+
# forms of it: downcased, split into lines, stripped of registry commentary.
|
|
12
|
+
# Computing those here rather than in each rule keeps the rules to a few lines
|
|
13
|
+
# each and stops the same regexp work happening ten times per lookup.
|
|
14
|
+
class Context
|
|
15
|
+
attr_reader :response, :tld, :definition
|
|
16
|
+
|
|
17
|
+
# @param response [Response, nil] nil when no server was reached
|
|
18
|
+
# @param tld [String, nil] the matched suffix, with a leading dot
|
|
19
|
+
# @param definition [Registry::Definition, nil]
|
|
20
|
+
def initialize(response:, tld: nil, definition: nil)
|
|
21
|
+
@response = response
|
|
22
|
+
@tld = tld
|
|
23
|
+
@definition = definition
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The response text with line endings normalised, or "" when absent.
|
|
27
|
+
def text
|
|
28
|
+
@text ||= response ? response.text : ""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def lower
|
|
32
|
+
@lower ||= text.downcase
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def empty?
|
|
36
|
+
text.strip.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def length
|
|
40
|
+
text.length
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The parsed RDAP document, or nil.
|
|
44
|
+
def json
|
|
45
|
+
response&.json
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rdap?
|
|
49
|
+
response ? response.rdap? : false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def http_status
|
|
53
|
+
response&.status
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def lines
|
|
57
|
+
@lines ||= text.split("\n").map(&:strip)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Lines that carry record data rather than registry commentary.
|
|
61
|
+
#
|
|
62
|
+
# This matters more than it looks. Registries put phrases like "not found"
|
|
63
|
+
# and "no match" in their legal preamble, and a keyword scan over the raw
|
|
64
|
+
# text picks those up and calls a registered domain free. Stripping comment
|
|
65
|
+
# prefixes and known boilerplate first is what makes the keyword rules safe
|
|
66
|
+
# enough to use at all.
|
|
67
|
+
def significant_lines
|
|
68
|
+
@significant_lines ||= lines.reject do |line|
|
|
69
|
+
line.empty? ||
|
|
70
|
+
Patterns::COMMENT_PREFIXES.any? { |prefix| line.start_with?(prefix) } ||
|
|
71
|
+
Patterns::BOILERPLATE.any? { |pattern| pattern.match?(line) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def significant_text
|
|
76
|
+
@significant_text ||= significant_lines.join("\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def significant_lower
|
|
80
|
+
@significant_lower ||= significant_text.downcase
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [String, nil] the matched text, for use as verdict evidence
|
|
84
|
+
def find(patterns, source: :text)
|
|
85
|
+
subject = subject_for(source)
|
|
86
|
+
|
|
87
|
+
patterns.each do |pattern|
|
|
88
|
+
match = pattern.match(subject)
|
|
89
|
+
return trim(match[0]) if match
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def match?(patterns, source: :text)
|
|
96
|
+
!find(patterns, source: source).nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Integer] how many of +patterns+ appear at least once
|
|
100
|
+
def count(patterns, source: :text)
|
|
101
|
+
subject = subject_for(source)
|
|
102
|
+
patterns.count { |pattern| pattern.match?(subject) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Substring search, for the plain keyword list.
|
|
106
|
+
#
|
|
107
|
+
# @return [String, nil] the keyword that matched
|
|
108
|
+
def find_keyword(keywords, source: :significant)
|
|
109
|
+
subject = source == :significant ? significant_lower : lower
|
|
110
|
+
keywords.find { |keyword| subject.include?(keyword) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A short excerpt for diagnostics, so a surprising verdict can be explained
|
|
114
|
+
# without dumping a whole record into a log.
|
|
115
|
+
def preview(limit = 200)
|
|
116
|
+
collapsed = text.strip.gsub(/\s+/, " ")
|
|
117
|
+
collapsed.length > limit ? "#{collapsed[0, limit]}..." : collapsed
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def subject_for(source)
|
|
123
|
+
case source
|
|
124
|
+
when :significant then significant_text
|
|
125
|
+
when :lower then lower
|
|
126
|
+
else text
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def trim(matched)
|
|
131
|
+
collapsed = matched.to_s.strip.gsub(/\s+/, " ")
|
|
132
|
+
collapsed.length > 120 ? "#{collapsed[0, 120]}..." : collapsed
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MonoVM
|
|
4
|
+
module Whois
|
|
5
|
+
module Availability
|
|
6
|
+
# The pattern tables the rules match against.
|
|
7
|
+
#
|
|
8
|
+
# Kept as frozen data, separate from the logic that reads it, for two reasons.
|
|
9
|
+
# Registries change their wording without warning, so this is the file that
|
|
10
|
+
# gets edited most often and it should be possible to edit it without
|
|
11
|
+
# understanding the chain. And every table here is a claim about what some
|
|
12
|
+
# registry actually emits, which is easier to review as a list than as
|
|
13
|
+
# conditionals scattered through methods.
|
|
14
|
+
module Patterns
|
|
15
|
+
# Separator between a key and its value.
|
|
16
|
+
#
|
|
17
|
+
# Traficom (.fi) and several others align values by padding the key with
|
|
18
|
+
# dots — +status.............: Registered+. A plain +status:+ match misses
|
|
19
|
+
# that, and a missed "Registered" is read as availability, so every field
|
|
20
|
+
# pattern is built through this rather than written literally.
|
|
21
|
+
SEP = '[\s._·\-]*:[ \t]*'
|
|
22
|
+
|
|
23
|
+
# Build a field-matching regexp source tolerant of padded keys and of
|
|
24
|
+
# multiple spaces inside the key name.
|
|
25
|
+
#
|
|
26
|
+
# field("name server") # matches "Name Server:", "name.server....:", "name server :"
|
|
27
|
+
#
|
|
28
|
+
# @return [String] regexp source, not a Regexp, so callers can compose it
|
|
29
|
+
def self.field(name)
|
|
30
|
+
name.to_s.split(":").map { |part| Regexp.escape(part.strip).gsub("\\ ", '\s+') }
|
|
31
|
+
.join(SEP).then { |source| "#{source}#{SEP}" }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Value of a status-like field equals one of +values+.
|
|
35
|
+
def self.status_is(*values)
|
|
36
|
+
"(?:domain[\\s_]*)?(?:registration[\\s_]*)?status#{SEP}(?:#{values.join("|")})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Server refused to answer (highest priority)
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# Wording registries use when they will not answer: rate limits, blocked
|
|
44
|
+
# clients, and port 43 endpoints retired in favour of RDAP.
|
|
45
|
+
#
|
|
46
|
+
# Without this table each of these responses would fall through the
|
|
47
|
+
# heuristics and come back "available" — a rate-limited registry would
|
|
48
|
+
# report every registered domain as free to register. This table is the
|
|
49
|
+
# single most important correctness guard in the library.
|
|
50
|
+
REFUSAL = [
|
|
51
|
+
# Rate limiting: .pl, .lu, .cz, .ru, .dk and others.
|
|
52
|
+
/request(?:s)?\s+limit\s+exceeded/i,
|
|
53
|
+
/quer(?:y|ies)\s+limit\s+exceeded/i,
|
|
54
|
+
/limit\s+exceeded/i,
|
|
55
|
+
/maximum\s+quer(?:y|ies)\s+rate/i,
|
|
56
|
+
/quer(?:y|ies)\s+rate\s+exceeded/i,
|
|
57
|
+
/excessive\s+querying/i,
|
|
58
|
+
/too\s+many\s+quer(?:y|ies)/i,
|
|
59
|
+
/too\s+many\s+requests/i,
|
|
60
|
+
/rate\s+limit(?:ed|ing)?/i,
|
|
61
|
+
/lookup\s+quota/i,
|
|
62
|
+
/quota\s+exceeded/i,
|
|
63
|
+
/you\s+have\s+exceeded\s+/i,
|
|
64
|
+
# Client blocked, or told to use a web form: .li, .ch, .it.
|
|
65
|
+
/requests?\s+of\s+this\s+client\s+(?:are|is)\s+not\s+permitted/i,
|
|
66
|
+
/access\s+to\s+this\s+whois\s+server\s+is\s+(?:denied|blocked)/i,
|
|
67
|
+
/your\s+(?:ip|host|access)\s+has\s+been\s+(?:blocked|banned|denied)/i,
|
|
68
|
+
/permission\s+denied/i,
|
|
69
|
+
# Port 43 retired: .shop and other GMO / Identity Digital registries.
|
|
70
|
+
/whois\s+service\s+has\s+been\s+retired/i,
|
|
71
|
+
/(?:queries|service)\s+(?:are|is)\s+now\s+served\s+via\s+rdap/i,
|
|
72
|
+
/please\s+use\s+(?:our\s+)?rdap/i,
|
|
73
|
+
/rdap\s+base\s+url/i,
|
|
74
|
+
# An HTTP error page delivered over port 43, or by a server that answered
|
|
75
|
+
# 200 with an error document. Narrow on purpose: a bare "access denied"
|
|
76
|
+
# can legitimately appear in a record whose details are withheld, so only
|
|
77
|
+
# the unambiguous HTTP shapes count as a refusal.
|
|
78
|
+
%r{\bhttp/1\.[01]\s+4\d\d\b}i,
|
|
79
|
+
/\b40[13]\s+(?:forbidden|unauthori[sz]ed)\b/i,
|
|
80
|
+
/\b429\s+too\s+many\s+requests\b/i,
|
|
81
|
+
/\b50[0-4]\s+(?:internal\s+server\s+error|bad\s+gateway|service\s+unavailable)\b/i,
|
|
82
|
+
# Transient server-side conditions.
|
|
83
|
+
/server\s+(?:is\s+)?busy/i,
|
|
84
|
+
/(?:please\s+)?try\s+again\s+later/i,
|
|
85
|
+
/service\s+(?:is\s+)?temporarily\s+unavailable/i,
|
|
86
|
+
/temporarily\s+unavailable/i,
|
|
87
|
+
/database\s+(?:is\s+)?unavailable/i,
|
|
88
|
+
/internal\s+server\s+error/i
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Wrong server for this TLD
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
# The server answered, but it does not serve this TLD — so its answer says
|
|
96
|
+
# nothing about the domain.
|
|
97
|
+
UNSUPPORTED = [
|
|
98
|
+
/tld\s+(?:is\s+)?not\s+supported/i,
|
|
99
|
+
/extension\s+(?:is\s+)?not\s+supported/i,
|
|
100
|
+
/unsupported\s+(?:tld|extension|domain)/i,
|
|
101
|
+
/domain\s+(?:extension|type)\s+not\s+supported/i,
|
|
102
|
+
/not\s+supported\s+by\s+this\s+whois\s+server/i,
|
|
103
|
+
/no\s+whois\s+(?:server|service)\s+(?:available|found|known)/i,
|
|
104
|
+
/whois\s+(?:server\s+)?not\s+(?:known|available|found)/i,
|
|
105
|
+
/(?:server|service)\s+not\s+(?:available|found)\s+for/i,
|
|
106
|
+
/whois\s+(?:service\s+)?not\s+available\s+for/i,
|
|
107
|
+
/this\s+server\s+does\s+not\s+(?:serve|handle)/i
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
# Banners of the IP-address registries.
|
|
111
|
+
#
|
|
112
|
+
# Reaching one of these means a TLD is mapped to the wrong server. They
|
|
113
|
+
# answer +%ERROR:101: no entries found+ to any domain query, which a naive
|
|
114
|
+
# detector reads as availability for every name under that TLD.
|
|
115
|
+
WRONG_REGISTRY_BANNERS = [
|
|
116
|
+
/this\s+is\s+the\s+ripe\s+database\s+query\s+service/i,
|
|
117
|
+
/the\s+objects\s+are\s+in\s+rpsl\s+format/i,
|
|
118
|
+
/\[whois\.apnic\.net\]/i,
|
|
119
|
+
/apnic\s+whois\s+service/i,
|
|
120
|
+
/american\s+registry\s+for\s+internet\s+numbers/i,
|
|
121
|
+
/whois\.arin\.net/i,
|
|
122
|
+
/lacnic\s+whois\s+server/i,
|
|
123
|
+
/afrinic\s+whois\s+server/i,
|
|
124
|
+
/%error:101/i
|
|
125
|
+
].freeze
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# The name is registered
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
# Statuses that mean the object exists, whatever state it is in. A domain in
|
|
132
|
+
# redemption, on server hold or pending delete is emphatically not free.
|
|
133
|
+
REGISTERED_STATUSES = %w[
|
|
134
|
+
registered active connect client.* redemption.*
|
|
135
|
+
pending.*delete server.?hold client.?hold allocated in\\s?use
|
|
136
|
+
suspended locked expired quarantine
|
|
137
|
+
].freeze
|
|
138
|
+
|
|
139
|
+
# General "this exists" wording, checked for every TLD.
|
|
140
|
+
UNAVAILABILITY = [
|
|
141
|
+
/---\s*not\s+available/i,
|
|
142
|
+
/---\s*not\s+found/i,
|
|
143
|
+
/---\s*domain\s+not\s+found/i,
|
|
144
|
+
/\bnot\s+available\s+for\s+registration\b/i,
|
|
145
|
+
/\bdomain\s+not\s+available\b/i,
|
|
146
|
+
Regexp.new(status_is("not available", "unavailable", "taken"), Regexp::IGNORECASE),
|
|
147
|
+
Regexp.new(status_is(*REGISTERED_STATUSES), Regexp::IGNORECASE),
|
|
148
|
+
/this\s+domain\s+has\s+been\s+registered/i,
|
|
149
|
+
/domain\s+(?:is\s+)?(?:already\s+|currently\s+)?registered/i,
|
|
150
|
+
/\bis\s+registered\b/i,
|
|
151
|
+
Regexp.new("#{field("registration")}registered", Regexp::IGNORECASE),
|
|
152
|
+
# Registry restriction and reservation notices: the object exists but the
|
|
153
|
+
# registry withholds its details. CIRA-backed registries answer
|
|
154
|
+
# "Error code: 01044 ... usage restrictions applied" for reserved names.
|
|
155
|
+
/usage\s+restrictions\s+applied/i,
|
|
156
|
+
/usage\s+restrictions/i,
|
|
157
|
+
/name\s+is\s+reserved/i,
|
|
158
|
+
/reserved\s+(?:domain|name)/i,
|
|
159
|
+
/domain\s+is\s+reserved/i,
|
|
160
|
+
/blocked\s+by\s+the\s+registry/i
|
|
161
|
+
].freeze
|
|
162
|
+
|
|
163
|
+
# Per-TLD wording that means registered. Consulted before the general table,
|
|
164
|
+
# because a few registries use words that are ambiguous elsewhere.
|
|
165
|
+
TLD_UNAVAILABILITY = {
|
|
166
|
+
".au" => [/---\s*not\s+available/i, /\bnot\s+available\b/i],
|
|
167
|
+
".com.au" => [/---\s*not\s+available/i, /\bnot\s+available\b/i],
|
|
168
|
+
".net.au" => [/---\s*not\s+available/i, /\bnot\s+available\b/i],
|
|
169
|
+
".org.au" => [/---\s*not\s+available/i, /\bnot\s+available\b/i],
|
|
170
|
+
".uk" => [/this\s+domain\s+has\s+been\s+registered/i, /\bregistered\b/i],
|
|
171
|
+
".co.uk" => [/this\s+domain\s+has\s+been\s+registered/i, /\bregistered\b/i],
|
|
172
|
+
# DENIC. "Status: invalid" means the name cannot be registered as spelled;
|
|
173
|
+
# it must never read as available.
|
|
174
|
+
".de" => [
|
|
175
|
+
Regexp.new(status_is("connect", "registered", "active", "invalid", "failed"),
|
|
176
|
+
Regexp::IGNORECASE)
|
|
177
|
+
],
|
|
178
|
+
".nl" => [Regexp.new(status_is("active", "in\\s?use"), Regexp::IGNORECASE)],
|
|
179
|
+
".be" => [Regexp.new(status_is("registered", "allocated", "not\\s+available"), Regexp::IGNORECASE)],
|
|
180
|
+
".ca" => [/domain\s+registered/i, Regexp.new(status_is("registered"), Regexp::IGNORECASE)],
|
|
181
|
+
".fi" => [Regexp.new(status_is("registered"), Regexp::IGNORECASE)],
|
|
182
|
+
".ir" => [/\bdomain\s*:/i]
|
|
183
|
+
}.freeze
|
|
184
|
+
|
|
185
|
+
# Fields that only appear in a real record. Counting them is a strong signal:
|
|
186
|
+
# a registry that answers with a registrar, a creation date and nameservers
|
|
187
|
+
# is describing something that exists.
|
|
188
|
+
REGISTRATION_INDICATORS = [
|
|
189
|
+
"domain", "domain name", "ascii", "nserver", "nameserver", "name server",
|
|
190
|
+
"registrar", "registrant", "registrar whois server", "registry domain id",
|
|
191
|
+
"creation date", "created", "created on", "registered on", "registration date",
|
|
192
|
+
"changed", "last updated", "updated date", "updated",
|
|
193
|
+
"expiry date", "expires", "expires on", "registry expiry date", "paid-till",
|
|
194
|
+
"admin contact", "technical contact", "billing contact", "tech-c", "admin-c",
|
|
195
|
+
"dnssec", "sponsoring registrar", "holder"
|
|
196
|
+
].map { |name| Regexp.new(field(name), Regexp::IGNORECASE) }.freeze
|
|
197
|
+
|
|
198
|
+
# The narrower set used to judge whether a response is a record at all.
|
|
199
|
+
REGISTRATION_FIELDS = [
|
|
200
|
+
"registrar", "registrant", "creation date", "created", "expiry date",
|
|
201
|
+
"expires", "name server", "nameserver", "nserver", "admin contact",
|
|
202
|
+
"technical contact", "paid-till", "sponsoring registrar"
|
|
203
|
+
].map { |name| Regexp.new(field(name), Regexp::IGNORECASE) }.freeze
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# The name is available
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
# Phrases that state a name is unregistered. Matched against the response
|
|
210
|
+
# with comment and banner lines removed, because registries put words like
|
|
211
|
+
# "not found" in their legal preamble.
|
|
212
|
+
AVAILABILITY_KEYWORDS = [
|
|
213
|
+
"no match", "not found", "no data found", "no entries found",
|
|
214
|
+
"no matching record", "no object found", "no such domain",
|
|
215
|
+
"object does not exist", "nothing found", "no domain",
|
|
216
|
+
"domain not found", "domain name not known", "not registered",
|
|
217
|
+
"available for registration", "is available for registration",
|
|
218
|
+
"is available for purchase", "domain is available",
|
|
219
|
+
"domain has not been registered", "domain name has not been registered",
|
|
220
|
+
"domain does not exist", "does not exist in database", "was not found",
|
|
221
|
+
"not exist", "object_not_found", "free",
|
|
222
|
+
# Spanish and Portuguese registries.
|
|
223
|
+
"no se encontro el objeto", "el dominio no se encuentra registrado",
|
|
224
|
+
"no está registrado", "dominio no registrado",
|
|
225
|
+
# French, German, Dutch.
|
|
226
|
+
"aucun objet trouvé", "nicht vergeben", "is vrij", "vrij"
|
|
227
|
+
].map(&:freeze).freeze
|
|
228
|
+
|
|
229
|
+
# Regexp forms, matched against the whole response. Broader than the keyword
|
|
230
|
+
# list because whitespace between words varies by registry.
|
|
231
|
+
NO_MATCH = [
|
|
232
|
+
/\bno\s+match\b/i,
|
|
233
|
+
/\bnot\s+found\b/i,
|
|
234
|
+
/\bno\s+data\s+found\b/i,
|
|
235
|
+
/\bno\s+entries\s+found\b/i,
|
|
236
|
+
/\bno\s+matching\s+record/i,
|
|
237
|
+
/\bno\s+object\s+found\b/i,
|
|
238
|
+
/object\s+does\s+not\s+exist/i,
|
|
239
|
+
/\bno\s+such\s+domain\b/i,
|
|
240
|
+
/domain\s+not\s+found/i,
|
|
241
|
+
/domain\s+name\s+not\s+known/i,
|
|
242
|
+
/domain\s+(?:name\s+)?has\s+not\s+been\s+registered/i,
|
|
243
|
+
/domain\s+(?:is\s+)?available/i,
|
|
244
|
+
/\bis\s+available\s+for\b/i,
|
|
245
|
+
/domain\s+does\s+not\s+exist/i,
|
|
246
|
+
/does\s+not\s+exist\s+in\s+database/i,
|
|
247
|
+
/\bwas\s+not\s+found\b/i,
|
|
248
|
+
/object_not_found/i,
|
|
249
|
+
/no\s+se\s+encontro\s+el\s+objeto/i,
|
|
250
|
+
/el\s+dominio\s+no\s+se\s+encuentra\s+registrado/i,
|
|
251
|
+
/no\s+está\s+registrado/i,
|
|
252
|
+
/---\s*available/i,
|
|
253
|
+
/%error:103/i
|
|
254
|
+
].freeze
|
|
255
|
+
|
|
256
|
+
# An explicit status field saying the name is free.
|
|
257
|
+
STATUS_AVAILABLE = [
|
|
258
|
+
Regexp.new(status_is("available", "free", "not\\s+registered", "no\\s+object"),
|
|
259
|
+
Regexp::IGNORECASE),
|
|
260
|
+
/availability#{SEP}available/i,
|
|
261
|
+
/state#{SEP}available/i,
|
|
262
|
+
/status\s*=\s*available/i,
|
|
263
|
+
/\bstatus:\s*AVAILABLE\b/
|
|
264
|
+
].freeze
|
|
265
|
+
|
|
266
|
+
# Per-TLD availability wording, consulted late. Several of these are single
|
|
267
|
+
# words like "available" that would be far too loose as a general rule but
|
|
268
|
+
# are unambiguous in a given registry's output format.
|
|
269
|
+
TLD_AVAILABILITY = {
|
|
270
|
+
".com" => [/no\s+match\s+for/i, /domain\s+not\s+found/i],
|
|
271
|
+
".net" => [/no\s+match\s+for/i, /domain\s+not\s+found/i],
|
|
272
|
+
".org" => [/domain\s+not\s+found/i, /\bnot\s+found\b/i],
|
|
273
|
+
".edu" => [/no\s+match\s+for/i],
|
|
274
|
+
".uk" => [/\bno\s+match\b/i, /this\s+domain\s+is\s+available/i],
|
|
275
|
+
".co.uk" => [/\bno\s+match\b/i, /this\s+domain\s+is\s+available/i],
|
|
276
|
+
".de" => [/is\s+available\s+for\s+registration/i, /status#{SEP}free/i],
|
|
277
|
+
".fr" => [/\bnot\s+found\b/i, /available/i],
|
|
278
|
+
".it" => [/\bavailable\b/i, /status#{SEP}available/i],
|
|
279
|
+
".au" => [/---\s*available/i, /is\s+available\s+for\s+registration/i],
|
|
280
|
+
".com.au" => [/is\s+available\s+for\s+registration/i, /\bavailable\b/i],
|
|
281
|
+
".be" => [/status#{SEP}available/i, /\bfree\b/i],
|
|
282
|
+
".ca" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
283
|
+
".ch" => [/---\s*1:/i, /\bavailable\b/i, /\bwe\s+do\s+not\s+have\s+an\s+entry\b/i],
|
|
284
|
+
".li" => [/\bavailable\b/i, /\bwe\s+do\s+not\s+have\s+an\s+entry\b/i],
|
|
285
|
+
".eu" => [/status#{SEP}available/i, /\bavailable\b/i],
|
|
286
|
+
".nl" => [/\bis\s+free\b/i, /\bavailable\b/i],
|
|
287
|
+
".dk" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
|
|
288
|
+
".no" => [/\bno\s+match\b/i, /\bavailable\b/i],
|
|
289
|
+
".se" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
290
|
+
".nu" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
291
|
+
".fi" => [/\bavailable\b/i, /domain\s+not\s+found/i],
|
|
292
|
+
".pt" => [/\bavailable\b/i, /\bno\s+match\b/i],
|
|
293
|
+
".es" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
294
|
+
".jp" => [/no\s+match!!/i, /\bno\s+match\b/i],
|
|
295
|
+
".cn" => [/no\s+matching\s+record/i, /\bnot\s+found\b/i],
|
|
296
|
+
".in" => [/\bnot\s+found\b/i, /\bno\s+data\s+found\b/i],
|
|
297
|
+
".hk" => [/the\s+domain\s+has\s+not\s+been\s+registered/i],
|
|
298
|
+
".tw" => [/\bno\s+found\b/i, /\bnot\s+found\b/i],
|
|
299
|
+
".kr" => [/\bnot\s+found\b/i, /above\s+domain\s+name\s+is\s+not\s+registered/i],
|
|
300
|
+
".sg" => [/---\s*not\s+found/i, /domain\s+not\s+found/i],
|
|
301
|
+
".my" => [/does\s+not\s+exist\s+in\s+database/i],
|
|
302
|
+
".ph" => [/domain\s+is\s+available/i],
|
|
303
|
+
".th" => [/no\s+match\s+found/i],
|
|
304
|
+
".vn" => [/\bavailable\b/i, /\bnot\s+found\b/i],
|
|
305
|
+
".id" => [/domain\s+not\s+found/i, /\bavailable\b/i],
|
|
306
|
+
".us" => [/\bnot\s+found\b/i, /domain\s+not\s+found/i],
|
|
307
|
+
".mx" => [/no_se_encontro_el_objeto/i, /\bnot\s+found\b/i],
|
|
308
|
+
".br" => [/no\s+match\s+for/i, /domain\s+not\s+found/i],
|
|
309
|
+
".ar" => [/el\s+dominio\s+no\s+se\s+encuentra\s+registrado/i],
|
|
310
|
+
".co" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
311
|
+
".cl" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
|
|
312
|
+
".pe" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
313
|
+
".ru" => [/\bno\s+entries\s+found\b/i, /\bnot\s+found\b/i],
|
|
314
|
+
".su" => [/\bno\s+entries\s+found\b/i],
|
|
315
|
+
".pl" => [/no\s+information\s+available/i, /\bavailable\b/i],
|
|
316
|
+
".cz" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
|
|
317
|
+
".sk" => [/domain\s+not\s+found/i, /\bavailable\b/i],
|
|
318
|
+
".hu" => [/\bno\s+match\b/i, /\bavailable\b/i],
|
|
319
|
+
".ro" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
|
|
320
|
+
".rs" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
321
|
+
".me" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
322
|
+
".md" => [/\bno\s+object\s+found\b/i, /\bavailable\b/i],
|
|
323
|
+
".ua" => [/\bno\s+entries\s+found\b/i, /\bavailable\b/i],
|
|
324
|
+
".za" => [/\bavailable\b/i, /\bnot\s+found\b/i],
|
|
325
|
+
".co.za" => [/\bavailable\b/i, /\bnot\s+found\b/i],
|
|
326
|
+
".ng" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
327
|
+
".ke" => [/\bno\s+object\s+found\b/i, /\bavailable\b/i],
|
|
328
|
+
".ma" => [/\bno\s+object\s+found\b/i, /\bavailable\b/i],
|
|
329
|
+
".nz" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
330
|
+
".ws" => [/the\s+queried\s+object\s+does\s+not\s+exist/i],
|
|
331
|
+
".cc" => [/\bno\s+match\b/i, /\bavailable\b/i],
|
|
332
|
+
".tv" => [/\bno\s+match\b/i, /\bavailable\b/i],
|
|
333
|
+
".to" => [/no\s+match\s+for/i, /\bavailable\b/i],
|
|
334
|
+
".im" => [/\bwas\s+not\s+found\b/i, /\bavailable\b/i],
|
|
335
|
+
".io" => [/---\s*domain\s+not\s+found/i, /\bavailable\b/i],
|
|
336
|
+
".sh" => [/domain\s+not\s+found/i, /\bavailable\b/i],
|
|
337
|
+
".ac" => [/domain\s+not\s+found/i, /\bavailable\b/i],
|
|
338
|
+
".gg" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
339
|
+
".je" => [/\bnot\s+found\b/i, /\bavailable\b/i],
|
|
340
|
+
".tk" => [/domain\s+name\s+not\s+known/i, /\bavailable\b/i],
|
|
341
|
+
".cm" => [/\bnot\s+registered\b/i, /\bavailable\b/i],
|
|
342
|
+
".ir" => [/\bno\s+entries\s+found\b/i],
|
|
343
|
+
".gr" => [/\bnot\s+exist\b/i],
|
|
344
|
+
".is" => [/\bno\s+entries\s+found\b/i],
|
|
345
|
+
".lt" => [/\bavailable\b/i, /status#{SEP}available/i],
|
|
346
|
+
".lv" => [/status#{SEP}free/i, /\bavailable\b/i],
|
|
347
|
+
".ee" => [/\bdomain\s+not\s+found\b/i],
|
|
348
|
+
".bg" => [/\bdoes\s+not\s+exist\b/i],
|
|
349
|
+
".hr" => [/\bnot\s+found\b/i],
|
|
350
|
+
".si" => [/\bno\s+entries\s+found\b/i],
|
|
351
|
+
".at" => [/\bnothing\s+found\b/i, /status#{SEP}free/i],
|
|
352
|
+
".lu" => [/\bno\s+such\s+domain\b/i]
|
|
353
|
+
}.freeze
|
|
354
|
+
|
|
355
|
+
# ------------------------------------------------------------------
|
|
356
|
+
# Guards
|
|
357
|
+
# ------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
# Text that means "this response is a notice, not a record". Absence of
|
|
360
|
+
# registration fields is only evidence of availability when none of these is
|
|
361
|
+
# present — an error page has no registration fields either.
|
|
362
|
+
ERROR_OR_RESTRICTION = [
|
|
363
|
+
/error\s+code#{SEP}/i,
|
|
364
|
+
/error\s+message#{SEP}/i,
|
|
365
|
+
/usage\s+restrictions/i,
|
|
366
|
+
/please\s+see\s+your\s+registrar/i,
|
|
367
|
+
/please\s+contact/i,
|
|
368
|
+
/\bis\s+reserved\b/i,
|
|
369
|
+
/reserved\s+name/i,
|
|
370
|
+
/\brestricted\b/i,
|
|
371
|
+
/access\s+denied/i,
|
|
372
|
+
/not\s+authori[sz]ed/i,
|
|
373
|
+
/unauthori[sz]ed/i,
|
|
374
|
+
/\bmalformed\b/i,
|
|
375
|
+
/invalid\s+(?:query|request|domain|input)/i,
|
|
376
|
+
/syntax\s+error/i
|
|
377
|
+
].freeze
|
|
378
|
+
|
|
379
|
+
# Line prefixes that mark registry commentary rather than record data.
|
|
380
|
+
# Registries routinely use words like "not found" in their legal preamble.
|
|
381
|
+
COMMENT_PREFIXES = ["%", "#", ";", ">>>", "---", "*", "//"].freeze
|
|
382
|
+
|
|
383
|
+
# Boilerplate lines that survive prefix stripping but are still commentary.
|
|
384
|
+
BOILERPLATE = [
|
|
385
|
+
/available\s+on\s+web\s+at/i,
|
|
386
|
+
/find\s+the\s+terms\s+and\s+conditions/i,
|
|
387
|
+
/terms\s+of\s+use/i,
|
|
388
|
+
/by\s+submitting\s+(?:a|this|any)\s+quer/i,
|
|
389
|
+
/for\s+more\s+information\s+(?:on|about)\s+whois/i,
|
|
390
|
+
/whois\s+(?:database|server)\s+(?:is\s+)?provided/i,
|
|
391
|
+
/the\s+data\s+(?:in|contained)/i,
|
|
392
|
+
/this\s+(?:data|information)\s+is\s+provided/i,
|
|
393
|
+
/notice#{SEP}/i,
|
|
394
|
+
/copyright/i,
|
|
395
|
+
/all\s+rights\s+reserved/i,
|
|
396
|
+
/personal\s+data/i,
|
|
397
|
+
/gdpr/i,
|
|
398
|
+
/redacted\s+for\s+privacy/i
|
|
399
|
+
].freeze
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
# RDAP
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
# Keys that only appear in an RDAP object describing a real registration.
|
|
406
|
+
RDAP_REGISTRATION_KEYS = %w[
|
|
407
|
+
ldhName handle nameservers secureDNS entities events status
|
|
408
|
+
].freeze
|
|
409
|
+
|
|
410
|
+
# RDAP error codes that mean "no such object", i.e. the name is free.
|
|
411
|
+
RDAP_NOT_FOUND_CODES = [404].freeze
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|