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
|
+
module MonoVM
|
|
4
|
+
module Whois
|
|
5
|
+
module Registry
|
|
6
|
+
# A name split into its registrable parts, with the definition that serves it.
|
|
7
|
+
#
|
|
8
|
+
# Produced by {ServerRegistry#resolve}. Exists so the split and the definition
|
|
9
|
+
# travel together: knowing that +example.co.uk+ has SLD +example+ is only
|
|
10
|
+
# meaningful alongside the fact that +.co.uk+ is the suffix a registry
|
|
11
|
+
# actually serves.
|
|
12
|
+
class Resolution
|
|
13
|
+
attr_reader :domain, :sld, :tld, :definition
|
|
14
|
+
|
|
15
|
+
# @param domain [DomainName] the full name as queried
|
|
16
|
+
# @param sld [String] the label immediately left of the suffix, ASCII
|
|
17
|
+
# @param tld [String] the matched suffix, with a leading dot, ASCII — this is
|
|
18
|
+
# the key the definitions and the per-TLD pattern tables are indexed by
|
|
19
|
+
# @param definition [Definition] how to look this suffix up
|
|
20
|
+
# @param registrable [DomainName, nil] the registrable name in its *Unicode*
|
|
21
|
+
# form. Passed in because only the caller that did the splitting still knows
|
|
22
|
+
# the Unicode labels; rebuilding it from the ASCII +sld+ and +tld+ would
|
|
23
|
+
# lose them, and the registries that insist on Unicode over port 43 would
|
|
24
|
+
# silently be sent Punycode.
|
|
25
|
+
def initialize(domain:, sld:, tld:, definition:, registrable: nil)
|
|
26
|
+
@domain = domain
|
|
27
|
+
@sld = sld
|
|
28
|
+
@tld = tld
|
|
29
|
+
@definition = definition
|
|
30
|
+
# Built here, not memoised: the instance is frozen, so a lazy ivar would
|
|
31
|
+
# raise FrozenError on first use.
|
|
32
|
+
@registrable = registrable || DomainName.parse("#{sld}#{tld}")
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The registrable name — the SLD plus its suffix, with any subdomain
|
|
37
|
+
# dropped. A lookup for +www.example.co.uk+ must query +example.co.uk+,
|
|
38
|
+
# because that is the object the registry holds. In Unicode form; call
|
|
39
|
+
# {#query} for the wire form.
|
|
40
|
+
attr_reader :registrable
|
|
41
|
+
|
|
42
|
+
# The ASCII form to put on the wire.
|
|
43
|
+
def query
|
|
44
|
+
registrable.ascii
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{ domain: domain.to_s, sld: sld, tld: tld, registrable: registrable.to_s }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inspect
|
|
52
|
+
"#<#{self.class.name} #{sld}|#{tld}>"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "definition"
|
|
4
|
+
require_relative "resolution"
|
|
5
|
+
require_relative "sources/base"
|
|
6
|
+
require_relative "sources/json_file"
|
|
7
|
+
require_relative "sources/iana_bootstrap"
|
|
8
|
+
require_relative "../domain_name"
|
|
9
|
+
require_relative "../paths"
|
|
10
|
+
|
|
11
|
+
module MonoVM
|
|
12
|
+
module Whois
|
|
13
|
+
module Registry
|
|
14
|
+
# Answers two questions: which suffix of a name is its TLD, and how is that
|
|
15
|
+
# TLD looked up.
|
|
16
|
+
#
|
|
17
|
+
# It owns no data of its own — definitions come from an ordered list of
|
|
18
|
+
# {Sources::Base} objects, later sources overriding earlier ones. The default
|
|
19
|
+
# order is bundled WHOIS list, then the IANA RDAP bootstrap, then an optional
|
|
20
|
+
# user override file, so a deployment can correct a stale registry entry
|
|
21
|
+
# without a gem release.
|
|
22
|
+
class ServerRegistry
|
|
23
|
+
attr_reader :sources
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# The registry as most callers want it.
|
|
27
|
+
#
|
|
28
|
+
# @param override_path [String, nil] extra definitions, highest priority
|
|
29
|
+
# @return [ServerRegistry]
|
|
30
|
+
def default(override_path: Paths.override)
|
|
31
|
+
sources = [
|
|
32
|
+
Sources::JsonFile.new(path: Paths::WHOIS_SERVERS, name: "bundled"),
|
|
33
|
+
Sources::IanaBootstrap.new
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
if override_path
|
|
37
|
+
sources << Sources::JsonFile.new(path: override_path, optional: true, name: "override")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
new(sources: sources)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param sources [Array<Sources::Base>] consulted in order; later wins
|
|
45
|
+
def initialize(sources:)
|
|
46
|
+
raise ArgumentError, "at least one source is required" if sources.nil? || sources.empty?
|
|
47
|
+
|
|
48
|
+
@sources = sources
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Every TLD this registry can look up, keyed with a leading dot.
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash{String => Definition}]
|
|
55
|
+
def definitions
|
|
56
|
+
# Bulk checks resolve concurrently, and the first of those threads would
|
|
57
|
+
# otherwise trigger this build several times over.
|
|
58
|
+
@definitions || @mutex.synchronize { @definitions ||= build }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param tld [String, nil] with or without a leading dot
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def supports?(tld)
|
|
64
|
+
!definition_for(tld).nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param tld [String, nil]
|
|
68
|
+
# @return [Definition, nil]
|
|
69
|
+
def definition_for(tld)
|
|
70
|
+
return nil if tld.nil?
|
|
71
|
+
|
|
72
|
+
key = tld.to_s.strip.downcase
|
|
73
|
+
key = ".#{key}" unless key.start_with?(".")
|
|
74
|
+
definitions[key]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Array<String>] sorted, dotted TLDs
|
|
78
|
+
def supported_tlds
|
|
79
|
+
definitions.keys.sort
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def size
|
|
83
|
+
definitions.size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Split +name+ at its longest known suffix.
|
|
87
|
+
#
|
|
88
|
+
# Longest-first is what makes multi-label suffixes and subdomains both land
|
|
89
|
+
# on the registrable name: +example.co.uk+ resolves against +.co.uk+ rather
|
|
90
|
+
# than +.uk+, and +www.example.com+ resolves to +example.com+ rather than
|
|
91
|
+
# being queried as-is.
|
|
92
|
+
#
|
|
93
|
+
# @param name [DomainName, String]
|
|
94
|
+
# @return [Resolution, nil] nil when no suffix of the name is known
|
|
95
|
+
def resolve(name)
|
|
96
|
+
domain = name.is_a?(DomainName) ? name : DomainName.parse(name)
|
|
97
|
+
labels = domain.ascii_labels
|
|
98
|
+
return nil if labels.length < 2
|
|
99
|
+
|
|
100
|
+
# The Unicode labels line up one-for-one with the ASCII ones, so the split
|
|
101
|
+
# found below can be applied to either form.
|
|
102
|
+
unicode_labels = domain.labels
|
|
103
|
+
|
|
104
|
+
# Start at the longest candidate suffix and shrink, so ".co.uk" is tried
|
|
105
|
+
# before ".uk".
|
|
106
|
+
(1...labels.length).each do |index|
|
|
107
|
+
candidate = ".#{labels[index..].join(".")}"
|
|
108
|
+
definition = definitions[candidate]
|
|
109
|
+
next if definition.nil?
|
|
110
|
+
|
|
111
|
+
return Resolution.new(
|
|
112
|
+
domain: domain,
|
|
113
|
+
sld: labels[index - 1],
|
|
114
|
+
tld: candidate,
|
|
115
|
+
definition: definition,
|
|
116
|
+
registrable: registrable_from(unicode_labels, index)
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Drop the memoised definitions so the next call re-reads every source.
|
|
124
|
+
# Mainly for tests and for long-running processes that refresh a data file.
|
|
125
|
+
def reload
|
|
126
|
+
@mutex.synchronize { @definitions = nil }
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def inspect
|
|
131
|
+
"#<#{self.class.name} #{@definitions ? "#{@definitions.size} tlds" : "not loaded"} " \
|
|
132
|
+
"sources=#{sources.map(&:name).join(",")}>"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# Rebuild the registrable name from the Unicode labels, keeping the same split
|
|
138
|
+
# point the ASCII match found. Returns nil if the two label lists somehow
|
|
139
|
+
# disagree in length, in which case {Resolution} falls back to the ASCII form.
|
|
140
|
+
def registrable_from(unicode_labels, index)
|
|
141
|
+
return nil if index > unicode_labels.length - 1
|
|
142
|
+
|
|
143
|
+
DomainName.parse([unicode_labels[index - 1], *unicode_labels[index..]].join("."))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build
|
|
147
|
+
merged = sources.each_with_object({}) do |source, accumulator|
|
|
148
|
+
source.load.each do |tld, definition|
|
|
149
|
+
existing = accumulator[tld]
|
|
150
|
+
accumulator[tld] = existing ? existing.merge(definition) : definition
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if merged.empty?
|
|
155
|
+
raise DefinitionsError,
|
|
156
|
+
"no definitions loaded from #{sources.map(&:name).join(", ")}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
merged.select { |_tld, definition| definition.usable? }.freeze
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../definition"
|
|
4
|
+
require_relative "../../errors"
|
|
5
|
+
|
|
6
|
+
module MonoVM
|
|
7
|
+
module Whois
|
|
8
|
+
module Registry
|
|
9
|
+
module Sources
|
|
10
|
+
# Interface for anything that can supply TLD definitions.
|
|
11
|
+
#
|
|
12
|
+
# {ServerRegistry} depends on this, not on files: that is what lets a host
|
|
13
|
+
# application feed definitions from a database, an internal HTTP service or
|
|
14
|
+
# a hard-coded hash in a test, without the registry knowing. A source needs
|
|
15
|
+
# two methods, {#name} and {#load}.
|
|
16
|
+
class Base
|
|
17
|
+
# A short identifier recorded in {Definition#sources}, so a surprising
|
|
18
|
+
# endpoint can be traced back to whichever source supplied it.
|
|
19
|
+
#
|
|
20
|
+
# @return [String]
|
|
21
|
+
def name
|
|
22
|
+
self.class.name.to_s.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Hash{String => Definition}] keyed by TLD, with the leading dot
|
|
26
|
+
def load
|
|
27
|
+
raise NotImplementedError, "#{self.class} must implement #load"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Sources that cannot be read are usually optional (an override file that
|
|
31
|
+
# does not exist yet). Returning true here lets {ServerRegistry} skip a
|
|
32
|
+
# failure instead of aborting every lookup.
|
|
33
|
+
def optional?
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Normalise +com+, +.COM+ or + .com + to +.com+.
|
|
40
|
+
def normalise_tld(raw)
|
|
41
|
+
cleaned = raw.to_s.strip.downcase.sub(/\A\.+/, "").sub(/\.+\z/, "")
|
|
42
|
+
return nil if cleaned.empty?
|
|
43
|
+
|
|
44
|
+
".#{cleaned}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A TLD may be internationalised (.рф, .中国); definitions are keyed by the
|
|
48
|
+
# ASCII form because that is what a resolved name is matched against.
|
|
49
|
+
def ascii_tld(tld)
|
|
50
|
+
return nil if tld.nil?
|
|
51
|
+
|
|
52
|
+
DomainName.parse(tld.delete_prefix(".")).ascii.then { |ascii| ".#{ascii}" }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "../../paths"
|
|
6
|
+
|
|
7
|
+
module MonoVM
|
|
8
|
+
module Whois
|
|
9
|
+
module Registry
|
|
10
|
+
module Sources
|
|
11
|
+
# The IANA RDAP bootstrap registry (RFC 7484).
|
|
12
|
+
#
|
|
13
|
+
# IANA publishes the authoritative TLD -> RDAP base URL map at
|
|
14
|
+
# https://data.iana.org/rdap/dns.json. A snapshot ships with the gem, which
|
|
15
|
+
# is why this library can answer for far more TLDs than a hand-maintained
|
|
16
|
+
# port 43 list covers, and why those answers are structured JSON instead of
|
|
17
|
+
# registry prose.
|
|
18
|
+
#
|
|
19
|
+
# The file looks like:
|
|
20
|
+
#
|
|
21
|
+
# {
|
|
22
|
+
# "version": "1.0",
|
|
23
|
+
# "publication": "2026-07-23T02:00:03Z",
|
|
24
|
+
# "services": [
|
|
25
|
+
# [ ["com", "net"], ["https://rdap.verisign.com/com/v1/"] ]
|
|
26
|
+
# ]
|
|
27
|
+
# }
|
|
28
|
+
#
|
|
29
|
+
# Reading is deliberately offline. Fetching at load time would put a network
|
|
30
|
+
# round trip in front of every process boot and make the library's own
|
|
31
|
+
# startup depend on IANA being reachable; +rake data:refresh_rdap+ updates
|
|
32
|
+
# the snapshot instead.
|
|
33
|
+
class IanaBootstrap < Base
|
|
34
|
+
BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json"
|
|
35
|
+
|
|
36
|
+
# RDAP domain lookups live under +{base}/domain/{name}+ (RFC 7482 §3.1.3).
|
|
37
|
+
DOMAIN_PATH = "domain/"
|
|
38
|
+
|
|
39
|
+
attr_reader :path
|
|
40
|
+
|
|
41
|
+
def initialize(path: Paths::RDAP_BOOTSTRAP, optional: true)
|
|
42
|
+
@path = path
|
|
43
|
+
@optional = optional
|
|
44
|
+
super()
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def name
|
|
48
|
+
"iana-rdap"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def optional?
|
|
52
|
+
@optional
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [String, nil] the snapshot's publication timestamp
|
|
56
|
+
def published_at
|
|
57
|
+
document&.fetch("publication", nil)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Hash{String => Definition}]
|
|
61
|
+
def load
|
|
62
|
+
services = document&.fetch("services", nil)
|
|
63
|
+
return {} unless services.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
services.each_with_object({}) do |service, definitions|
|
|
66
|
+
endpoint = endpoint_for(service)
|
|
67
|
+
next if endpoint.nil?
|
|
68
|
+
|
|
69
|
+
tlds_in(service).each do |tld|
|
|
70
|
+
definitions[tld] ||= Definition.new(tld: tld, rdap_endpoint: endpoint, sources: [name])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def document
|
|
78
|
+
return @document if defined?(@document)
|
|
79
|
+
|
|
80
|
+
@document = read_document
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def read_document
|
|
84
|
+
raw = read
|
|
85
|
+
return nil if raw.nil?
|
|
86
|
+
|
|
87
|
+
parsed = JSON.parse(raw)
|
|
88
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
89
|
+
rescue JSON::ParserError => e
|
|
90
|
+
raise DefinitionsError, "#{path} is not valid JSON: #{e.message}" unless optional?
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def read
|
|
96
|
+
return File.read(path, encoding: "UTF-8") if File.file?(path)
|
|
97
|
+
return nil if optional?
|
|
98
|
+
|
|
99
|
+
raise DefinitionsError, "RDAP bootstrap not found at #{path}"
|
|
100
|
+
rescue SystemCallError => e
|
|
101
|
+
raise DefinitionsError, "cannot read #{path}: #{e.message}" unless optional?
|
|
102
|
+
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A service entry is +[[tld, ...], [url, ...]]+.
|
|
107
|
+
def tlds_in(service)
|
|
108
|
+
return [] unless service.is_a?(Array) && service[0].is_a?(Array)
|
|
109
|
+
|
|
110
|
+
service[0].filter_map { |tld| ascii_tld(normalise_tld(tld)) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def endpoint_for(service)
|
|
114
|
+
return nil unless service.is_a?(Array) && service[1].is_a?(Array)
|
|
115
|
+
|
|
116
|
+
url = preferred_url(service[1])
|
|
117
|
+
return nil if url.nil?
|
|
118
|
+
|
|
119
|
+
Endpoint.parse("#{url}#{DOMAIN_PATH}")
|
|
120
|
+
rescue DefinitionsError
|
|
121
|
+
# One malformed entry must not cost us the other 1,199.
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# HTTPS where a registry offers both: RDAP responses carry registrant
|
|
126
|
+
# data, and there is no reason to read it off the wire in clear text.
|
|
127
|
+
def preferred_url(urls)
|
|
128
|
+
candidates = urls.filter_map do |url|
|
|
129
|
+
text = url.to_s.strip
|
|
130
|
+
text.empty? ? nil : text
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
chosen = candidates.find { |url| url.start_with?("https://") } || candidates.first
|
|
134
|
+
return nil if chosen.nil?
|
|
135
|
+
|
|
136
|
+
chosen.end_with?("/") ? chosen : "#{chosen}/"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module MonoVM
|
|
7
|
+
module Whois
|
|
8
|
+
module Registry
|
|
9
|
+
module Sources
|
|
10
|
+
# Reads the server-list JSON format: an array of entries, each mapping a
|
|
11
|
+
# comma-separated +extensions+ list to one endpoint.
|
|
12
|
+
#
|
|
13
|
+
# [
|
|
14
|
+
# {
|
|
15
|
+
# "extensions": ".com,.net",
|
|
16
|
+
# "uri": "socket://whois.verisign-grs.com",
|
|
17
|
+
# "available": "No match for",
|
|
18
|
+
# "premium": "Reserved",
|
|
19
|
+
# "rdap": "https://rdap.verisign.com/com/v1/domain/",
|
|
20
|
+
# "available_when_empty": false,
|
|
21
|
+
# "comment": "why this entry looks like this"
|
|
22
|
+
# }
|
|
23
|
+
# ]
|
|
24
|
+
#
|
|
25
|
+
# Both the bundled list and a user override file use this class — they
|
|
26
|
+
# differ only in path and in whether a missing file is fatal.
|
|
27
|
+
class JsonFile < Base
|
|
28
|
+
attr_reader :path
|
|
29
|
+
|
|
30
|
+
# @param path [String]
|
|
31
|
+
# @param optional [Boolean] when true, a missing or unreadable file
|
|
32
|
+
# yields no definitions instead of raising
|
|
33
|
+
# @param name [String, nil] identifier recorded on each definition
|
|
34
|
+
def initialize(path:, optional: false, name: nil)
|
|
35
|
+
@path = path
|
|
36
|
+
@optional = optional
|
|
37
|
+
@name = name
|
|
38
|
+
super()
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def name
|
|
42
|
+
@name || (optional? ? "override" : "bundled")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def optional?
|
|
46
|
+
@optional
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Hash{String => Definition}]
|
|
50
|
+
def load
|
|
51
|
+
raw = read
|
|
52
|
+
return {} if raw.nil?
|
|
53
|
+
|
|
54
|
+
entries = parse(raw)
|
|
55
|
+
entries.each_with_index.with_object({}) do |(entry, index), definitions|
|
|
56
|
+
add_entry(definitions, entry, index)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def read
|
|
63
|
+
return File.read(path, encoding: "UTF-8") if File.file?(path)
|
|
64
|
+
return nil if optional?
|
|
65
|
+
|
|
66
|
+
raise DefinitionsError, "server definitions not found at #{path}"
|
|
67
|
+
rescue SystemCallError => e
|
|
68
|
+
raise DefinitionsError, "cannot read #{path}: #{e.message}" unless optional?
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse(raw)
|
|
74
|
+
parsed = JSON.parse(raw)
|
|
75
|
+
unless parsed.is_a?(Array)
|
|
76
|
+
raise DefinitionsError, "#{path} must hold a JSON array, got #{parsed.class}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
parsed
|
|
80
|
+
rescue JSON::ParserError => e
|
|
81
|
+
raise DefinitionsError, "#{path} is not valid JSON: #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def add_entry(definitions, entry, index)
|
|
85
|
+
unless entry.is_a?(Hash)
|
|
86
|
+
raise DefinitionsError, "#{path}: entry #{index} must be an object, got #{entry.class}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
whois = build_endpoint(entry["uri"], index)
|
|
90
|
+
rdap = build_endpoint(entry["rdap"], index)
|
|
91
|
+
return if whois.nil? && rdap.nil?
|
|
92
|
+
|
|
93
|
+
tlds_in(entry).each do |tld|
|
|
94
|
+
definition = definition_for(tld, entry, whois, rdap)
|
|
95
|
+
definitions[tld] = definitions.key?(tld) ? definitions[tld].merge(definition) : definition
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def tlds_in(entry)
|
|
100
|
+
entry["extensions"].to_s.split(",").filter_map do |extension|
|
|
101
|
+
ascii_tld(normalise_tld(extension))
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def definition_for(tld, entry, whois, rdap)
|
|
106
|
+
Definition.new(
|
|
107
|
+
tld: tld,
|
|
108
|
+
whois_endpoint: whois,
|
|
109
|
+
rdap_endpoint: rdap,
|
|
110
|
+
available_match: entry["available"],
|
|
111
|
+
premium_match: entry["premium"],
|
|
112
|
+
available_when_empty: truthy?(entry["available_when_empty"]),
|
|
113
|
+
comment: entry["comment"],
|
|
114
|
+
sources: [name]
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_endpoint(uri, index)
|
|
119
|
+
return nil if uri.nil? || uri.to_s.strip.empty?
|
|
120
|
+
|
|
121
|
+
Endpoint.parse(uri)
|
|
122
|
+
rescue DefinitionsError => e
|
|
123
|
+
raise DefinitionsError, "#{path}: entry #{index} has a bad endpoint: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# JSON gives real booleans, but hand-maintained files and environment
|
|
127
|
+
# overrides tend to carry strings.
|
|
128
|
+
def truthy?(value)
|
|
129
|
+
return value if [true, false].include?(value)
|
|
130
|
+
|
|
131
|
+
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
# One raw answer from one server.
|
|
8
|
+
#
|
|
9
|
+
# Transports return this; availability rules and parsers read it. Keeping the
|
|
10
|
+
# raw body verbatim matters — callers of a WHOIS library legitimately want to
|
|
11
|
+
# see exactly what the registry said, so nothing here mutates {#body}.
|
|
12
|
+
class Response
|
|
13
|
+
attr_reader :body, :endpoint, :query, :status, :elapsed
|
|
14
|
+
|
|
15
|
+
# @param body [String] exactly what the server sent
|
|
16
|
+
# @param endpoint [Endpoint] where it came from
|
|
17
|
+
# @param query [String] the name that was asked about
|
|
18
|
+
# @param status [Integer, nil] HTTP status, nil for port 43
|
|
19
|
+
# @param elapsed [Float, nil] seconds spent on the exchange
|
|
20
|
+
def initialize(body:, endpoint:, query:, status: nil, elapsed: nil)
|
|
21
|
+
@body = body.to_s.freeze
|
|
22
|
+
@endpoint = endpoint
|
|
23
|
+
@query = query
|
|
24
|
+
@status = status
|
|
25
|
+
@elapsed = elapsed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Not frozen as a whole: {#text} and {#json} are memoised, and parsing a large
|
|
29
|
+
# RDAP document on every rule that asks for it would be wasted work. The body
|
|
30
|
+
# itself is frozen, so the response is still effectively immutable.
|
|
31
|
+
|
|
32
|
+
def rdap?
|
|
33
|
+
endpoint.kind == :rdap
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def whois?
|
|
37
|
+
endpoint.kind == :whois
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def empty?
|
|
41
|
+
body.strip.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The body as text, with line endings normalised to \n. Availability rules
|
|
45
|
+
# match against this so a pattern anchored to a line start behaves the same
|
|
46
|
+
# whether the registry sent CRLF or LF.
|
|
47
|
+
def text
|
|
48
|
+
@text ||= body.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
|
|
49
|
+
.gsub(/\r\n?/, "\n")
|
|
50
|
+
.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The parsed RDAP document, or nil when the body is not JSON.
|
|
54
|
+
#
|
|
55
|
+
# Memoised including the nil case: a non-JSON body is re-checked by several
|
|
56
|
+
# rules and reparsing it each time is wasted work.
|
|
57
|
+
def json
|
|
58
|
+
return @json if defined?(@json)
|
|
59
|
+
|
|
60
|
+
@json = begin
|
|
61
|
+
parsed = JSON.parse(body)
|
|
62
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def json?
|
|
69
|
+
!json.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_h
|
|
73
|
+
{
|
|
74
|
+
query: query,
|
|
75
|
+
endpoint: endpoint.to_s,
|
|
76
|
+
kind: endpoint.kind,
|
|
77
|
+
status: status,
|
|
78
|
+
elapsed: elapsed,
|
|
79
|
+
length: body.length
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inspect
|
|
84
|
+
"#<#{self.class.name} query=#{query.inspect} endpoint=#{endpoint} " \
|
|
85
|
+
"status=#{status.inspect} bytes=#{body.length}>"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|