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,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
|