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,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MonoVM
|
|
4
|
+
module Whois
|
|
5
|
+
# Base class for every error this gem raises.
|
|
6
|
+
#
|
|
7
|
+
# A library that raises bare exceptions leaves callers rescuing everything
|
|
8
|
+
# or nothing. Here the taxonomy is meaningful: callers who
|
|
9
|
+
# just want "did it blow up" rescue {Error}, while callers that retry on a
|
|
10
|
+
# busy registry but give up on a bad domain can discriminate.
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
# The input is not a usable domain name.
|
|
14
|
+
class InvalidDomainError < Error
|
|
15
|
+
attr_reader :domain
|
|
16
|
+
|
|
17
|
+
def initialize(domain, message = nil)
|
|
18
|
+
@domain = domain
|
|
19
|
+
super(message || "not a usable domain name: #{domain.inspect}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# No WHOIS or RDAP endpoint is known for this TLD.
|
|
24
|
+
class UnsupportedTldError < Error
|
|
25
|
+
attr_reader :tld
|
|
26
|
+
|
|
27
|
+
def initialize(tld, message = nil)
|
|
28
|
+
@tld = tld
|
|
29
|
+
super(message || "no whois server known for #{tld.nil? ? "a name without a TLD" : tld}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A server definition file is missing, unreadable or malformed.
|
|
34
|
+
class DefinitionsError < Error; end
|
|
35
|
+
|
|
36
|
+
# Raised when the transport could not reach the server at all.
|
|
37
|
+
class ConnectionError < Error; end
|
|
38
|
+
|
|
39
|
+
# The connection succeeded but the exchange exceeded the configured timeout.
|
|
40
|
+
class TimeoutError < ConnectionError; end
|
|
41
|
+
|
|
42
|
+
# The server answered, but refused to give a verdict: rate limiting, a
|
|
43
|
+
# blocked client, a retired port 43 endpoint, or an HTTP status that is not
|
|
44
|
+
# an answer (401/403/405/406/429/5xx).
|
|
45
|
+
#
|
|
46
|
+
# This is deliberately distinct from "the domain is registered". Conflating
|
|
47
|
+
# the two is how a permissive detector ends up reporting registered domains
|
|
48
|
+
# as free.
|
|
49
|
+
class ServerRefusedError < Error
|
|
50
|
+
attr_reader :endpoint
|
|
51
|
+
|
|
52
|
+
def initialize(message, endpoint: nil)
|
|
53
|
+
@endpoint = endpoint
|
|
54
|
+
super(message)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The server closed the connection without sending anything. An empty record
|
|
59
|
+
# is not evidence that a domain is unregistered, so this is an error rather
|
|
60
|
+
# than a verdict.
|
|
61
|
+
class EmptyResponseError < Error; end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "record"
|
|
5
|
+
|
|
6
|
+
module MonoVM
|
|
7
|
+
module Whois
|
|
8
|
+
module Parser
|
|
9
|
+
# Shared scaffolding for record parsers.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses say which responses they understand ({#applicable?}) and how to
|
|
12
|
+
# turn one into a {Record} ({#parse}). Date parsing lives here because it is
|
|
13
|
+
# the one job every registry does differently and none of them do well: the
|
|
14
|
+
# same field arrives as +2026-08-13T04:00:00Z+, +13-Aug-2026+, +2026.08.13+ or
|
|
15
|
+
# +13/08/2026+, and getting it wrong by a month is worse than returning nil.
|
|
16
|
+
class Base
|
|
17
|
+
# @param response [Response]
|
|
18
|
+
# @return [Boolean]
|
|
19
|
+
def applicable?(response)
|
|
20
|
+
raise NotImplementedError, "#{self.class} must implement #applicable?"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param response [Response]
|
|
24
|
+
# @return [Record]
|
|
25
|
+
def parse(response)
|
|
26
|
+
raise NotImplementedError, "#{self.class} must implement #parse"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name
|
|
30
|
+
@name ||= Base.snake_case(self.class)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# +IcannRdd+ becomes +"icann_rdd"+. Handles an anonymous class, whose
|
|
34
|
+
# +Module#name+ is nil — which a spec or a host application defining a parser
|
|
35
|
+
# inline would otherwise crash on.
|
|
36
|
+
def self.snake_case(klass)
|
|
37
|
+
base = klass.name.to_s.split("::").last
|
|
38
|
+
base = klass.inspect if base.nil? || base.empty?
|
|
39
|
+
base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Explicit formats, tried in order, before falling back to Ruby's parser.
|
|
43
|
+
#
|
|
44
|
+
# Ordering matters for the ambiguous ones: +13/08/2026+ is day-first
|
|
45
|
+
# everywhere that writes it with slashes in a WHOIS record, so that pattern
|
|
46
|
+
# comes before the month-first reading Time.parse would pick.
|
|
47
|
+
DATE_FORMATS = [
|
|
48
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
49
|
+
"%Y-%m-%dT%H:%M:%SZ",
|
|
50
|
+
"%Y-%m-%dT%H:%M:%S.%L%z",
|
|
51
|
+
"%Y-%m-%d %H:%M:%S%z",
|
|
52
|
+
"%Y-%m-%d %H:%M:%S",
|
|
53
|
+
"%Y-%m-%d",
|
|
54
|
+
"%Y.%m.%d %H:%M:%S",
|
|
55
|
+
"%Y.%m.%d",
|
|
56
|
+
"%Y/%m/%d",
|
|
57
|
+
"%d-%b-%Y %H:%M:%S",
|
|
58
|
+
"%d-%b-%Y",
|
|
59
|
+
"%d.%m.%Y %H:%M:%S",
|
|
60
|
+
"%d.%m.%Y",
|
|
61
|
+
"%d/%m/%Y %H:%M:%S",
|
|
62
|
+
"%d/%m/%Y",
|
|
63
|
+
"%b %d %Y",
|
|
64
|
+
"%d %b %Y",
|
|
65
|
+
"%Y%m%d"
|
|
66
|
+
].freeze
|
|
67
|
+
|
|
68
|
+
# Registration dates are never before the DNS existed and never centuries
|
|
69
|
+
# away. The range is what makes the ambiguous formats safe to try in order:
|
|
70
|
+
# "%Y.%m.%d" happily parses "12.5.2015" as year 12, month 5, day 2015 —
|
|
71
|
+
# rolling over into May of year 12 — and without this guard that nonsense wins
|
|
72
|
+
# before "%d.%m.%Y" is ever tried, silently misdating every European record.
|
|
73
|
+
PLAUSIBLE_YEARS = (1980..2200)
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# @return [Time, nil]
|
|
78
|
+
def parse_time(value)
|
|
79
|
+
text = value.to_s.strip
|
|
80
|
+
return nil if text.empty?
|
|
81
|
+
|
|
82
|
+
# Registries append notes to dates: "2026-08-13 (registry lock)".
|
|
83
|
+
text = text.sub(/\s*\(.*\)\s*\z/, "").strip
|
|
84
|
+
# A trailing timezone name after an offset confuses every strptime format.
|
|
85
|
+
text = text.sub(/\s+\((?:UTC|GMT)[^)]*\)\z/i, "").strip
|
|
86
|
+
return nil if text.empty?
|
|
87
|
+
|
|
88
|
+
from_formats(text) || from_fallback(text)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def from_formats(text)
|
|
92
|
+
DATE_FORMATS.each do |format|
|
|
93
|
+
parsed = begin
|
|
94
|
+
Time.strptime(text, format)
|
|
95
|
+
rescue ArgumentError, RangeError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
next if parsed.nil?
|
|
100
|
+
next unless PLAUSIBLE_YEARS.cover?(parsed.year)
|
|
101
|
+
# strptime also parses "2026" with "%Y%m%d" and calls it January 1st;
|
|
102
|
+
# requiring the year to appear in the input rejects that kind of match.
|
|
103
|
+
next unless text.include?(parsed.year.to_s)
|
|
104
|
+
|
|
105
|
+
return parsed
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def from_fallback(text)
|
|
112
|
+
Time.parse(text)
|
|
113
|
+
rescue ArgumentError, RangeError, TypeError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Registries answer with a literal for "no data"; those must not become
|
|
118
|
+
# strings that look like values.
|
|
119
|
+
def blank?(value)
|
|
120
|
+
text = value.to_s.strip
|
|
121
|
+
text.empty? || Record::REDACTIONS.include?(text.downcase)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "key_value"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Parser
|
|
8
|
+
# The ICANN Registrar Data Directory format used by gTLD registries.
|
|
9
|
+
#
|
|
10
|
+
# Structurally it is {KeyValue}, so almost everything is inherited. What earns a
|
|
11
|
+
# subclass is that this format carries the same fact twice and the two copies
|
|
12
|
+
# disagree: a +.com+ record holds both a +Registry Expiry Date+ and a
|
|
13
|
+
# +Registrar Registration Expiration Date+, and the registrar's copy is
|
|
14
|
+
# frequently stale. The registry is authoritative, so it wins here explicitly
|
|
15
|
+
# rather than by luck of alias ordering.
|
|
16
|
+
#
|
|
17
|
+
# It also drops the +>>> Last update of WHOIS database+ trailer, which is a
|
|
18
|
+
# timestamp for the database rather than for the domain and otherwise lands in
|
|
19
|
+
# {Record#updated_on}.
|
|
20
|
+
class IcannRdd < KeyValue
|
|
21
|
+
# Keys that only this format uses; two of them together is a confident match.
|
|
22
|
+
SIGNATURE_KEYS = [
|
|
23
|
+
"registry domain id",
|
|
24
|
+
"registrar whois server",
|
|
25
|
+
"registrar iana id",
|
|
26
|
+
"registry expiry date"
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
TRAILER = />>>\s*Last update of (?:the )?WHOIS database.*$/i
|
|
30
|
+
|
|
31
|
+
def applicable?(response)
|
|
32
|
+
return false if response.json?
|
|
33
|
+
|
|
34
|
+
text = response.text.downcase
|
|
35
|
+
SIGNATURE_KEYS.count { |key| text.include?("#{key}:") } >= 2
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse(response)
|
|
39
|
+
record = super
|
|
40
|
+
|
|
41
|
+
# The registry's expiry date is authoritative; only fall back to the
|
|
42
|
+
# registrar's if the registry did not supply one.
|
|
43
|
+
expires = parse_time(record["registry expiry date"]) || record.expires_on
|
|
44
|
+
|
|
45
|
+
Record.new(
|
|
46
|
+
domain: record.domain,
|
|
47
|
+
registry_id: record.registry_id,
|
|
48
|
+
registrar: record.registrar,
|
|
49
|
+
registrar_whois_server: record.registrar_whois_server,
|
|
50
|
+
registrar_url: record.registrar_url,
|
|
51
|
+
registrar_iana_id: record.registrar_iana_id,
|
|
52
|
+
registrant: record.registrant,
|
|
53
|
+
statuses: record.statuses.map { |status| strip_epp_url(status) },
|
|
54
|
+
nameservers: record.nameservers,
|
|
55
|
+
created_on: record.created_on,
|
|
56
|
+
updated_on: record.updated_on,
|
|
57
|
+
expires_on: expires,
|
|
58
|
+
dnssec: record.dnssec,
|
|
59
|
+
contacts: record.contacts,
|
|
60
|
+
fields: record.fields,
|
|
61
|
+
source: name
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def extract_pairs(text)
|
|
68
|
+
super(text.sub(TRAILER, ""))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Statuses arrive as "clientTransferProhibited https://icann.org/epp#..." —
|
|
72
|
+
# the URL is documentation, not part of the status.
|
|
73
|
+
def strip_epp_url(status)
|
|
74
|
+
status.split(/\s+/).first
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../availability/patterns"
|
|
5
|
+
|
|
6
|
+
module MonoVM
|
|
7
|
+
module Whois
|
|
8
|
+
module Parser
|
|
9
|
+
# Parses the +Key: value+ shape that nearly every port 43 registry speaks.
|
|
10
|
+
#
|
|
11
|
+
# There is no standard for this format, only a strong convention, so the work
|
|
12
|
+
# is in the disagreements: keys padded with dots to align values, the same field
|
|
13
|
+
# called +Creation Date+, +created+, +registered on+ or +paid-till+, and fields
|
|
14
|
+
# that legitimately repeat (a domain has several nameservers and several EPP
|
|
15
|
+
# statuses). {ALIASES} is where that variation is absorbed; everything else here
|
|
16
|
+
# is mechanical.
|
|
17
|
+
class KeyValue < Base
|
|
18
|
+
# +Key: value+, tolerating padded keys and values that contain colons.
|
|
19
|
+
LINE = /\A(?<key>[^:]{1,64}?)[\s._·-]*:[ \t]*(?<value>.*)\z/
|
|
20
|
+
|
|
21
|
+
# Canonical field name => the key spellings that mean it, most preferred
|
|
22
|
+
# first. Order matters where a record carries two candidates: a gTLD record
|
|
23
|
+
# has both a registry expiry date and the registrar's copy of it, and the
|
|
24
|
+
# registry is authoritative.
|
|
25
|
+
ALIASES = {
|
|
26
|
+
domain: ["domain name", "domain", "ascii", "domain-name", "the domain"],
|
|
27
|
+
registry_id: ["registry domain id"],
|
|
28
|
+
registrar: ["registrar", "sponsoring registrar", "registrar name", "registrar organization"],
|
|
29
|
+
registrar_whois_server: ["registrar whois server", "whois server"],
|
|
30
|
+
registrar_url: ["registrar url", "referral url", "registrar web"],
|
|
31
|
+
registrar_iana_id: ["registrar iana id"],
|
|
32
|
+
registrant: [
|
|
33
|
+
"registrant name", "registrant organization", "registrant organisation",
|
|
34
|
+
"registrant", "holder", "holder name", "organisation", "organization", "org"
|
|
35
|
+
],
|
|
36
|
+
created_on: [
|
|
37
|
+
"creation date", "created", "created on", "created date", "registered on",
|
|
38
|
+
"registration date", "registered", "domain registration date", "record created",
|
|
39
|
+
"activation date"
|
|
40
|
+
],
|
|
41
|
+
updated_on: [
|
|
42
|
+
"updated date", "last updated", "last update", "changed", "modified",
|
|
43
|
+
"last modified", "record last updated", "update date"
|
|
44
|
+
],
|
|
45
|
+
expires_on: [
|
|
46
|
+
"registry expiry date", "expiry date", "expiration date", "expires",
|
|
47
|
+
"expires on", "expire date", "paid-till", "renewal date", "record expires",
|
|
48
|
+
"registrar registration expiration date", "valid until", "expiry"
|
|
49
|
+
],
|
|
50
|
+
dnssec: ["dnssec", "dnssec signed", "signed"]
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Fields that may appear many times; every occurrence is kept.
|
|
54
|
+
MULTI = {
|
|
55
|
+
statuses: ["domain status", "status", "state", "eppstatus"],
|
|
56
|
+
nameservers: ["name server", "nameserver", "nserver", "ns", "dns", "name servers"]
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
# Contact blocks, addressed by key prefix: +Admin Email:+, +Tech Name:+.
|
|
60
|
+
CONTACT_ROLES = {
|
|
61
|
+
registrant: %w[registrant],
|
|
62
|
+
admin: ["admin", "administrative contact", "admin contact"],
|
|
63
|
+
tech: ["tech", "technical contact", "tech contact"],
|
|
64
|
+
billing: ["billing", "billing contact"]
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
CONTACT_ATTRIBUTES = {
|
|
68
|
+
name: "name",
|
|
69
|
+
organization: "organization",
|
|
70
|
+
email: "email",
|
|
71
|
+
phone: "phone",
|
|
72
|
+
country: "country",
|
|
73
|
+
city: "city",
|
|
74
|
+
state: "state/province"
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
def applicable?(response)
|
|
78
|
+
return false if response.json?
|
|
79
|
+
|
|
80
|
+
# LINE is anchored, so it has to be matched a line at a time — testing it
|
|
81
|
+
# against the whole response fails on anything multi-line, which is every
|
|
82
|
+
# real record.
|
|
83
|
+
response.text.each_line.any? { |line| LINE.match?(line.strip) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse(response)
|
|
87
|
+
pairs = extract_pairs(response.text)
|
|
88
|
+
|
|
89
|
+
Record.new(**attributes_from(pairs), fields: flatten(pairs), source: name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# {ALIASES}'s keys are deliberately {Record}'s keyword names, so resolving the
|
|
95
|
+
# single-valued fields is one transform rather than a line each.
|
|
96
|
+
def attributes_from(pairs)
|
|
97
|
+
resolved = ALIASES.transform_values { |keys| first(pairs, keys) }
|
|
98
|
+
|
|
99
|
+
resolved.merge(
|
|
100
|
+
statuses: all(pairs, MULTI[:statuses]),
|
|
101
|
+
nameservers: all(pairs, MULTI[:nameservers]),
|
|
102
|
+
created_on: parse_time(resolved[:created_on]),
|
|
103
|
+
updated_on: parse_time(resolved[:updated_on]),
|
|
104
|
+
expires_on: parse_time(resolved[:expires_on]),
|
|
105
|
+
contacts: extract_contacts(pairs)
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Hash{String => Array<String>}] normalised key => every value seen
|
|
110
|
+
def extract_pairs(text)
|
|
111
|
+
text.split("\n").each_with_object({}) do |raw_line, pairs|
|
|
112
|
+
line = raw_line.strip
|
|
113
|
+
next if line.empty? || comment?(line)
|
|
114
|
+
|
|
115
|
+
match = LINE.match(line)
|
|
116
|
+
next if match.nil?
|
|
117
|
+
|
|
118
|
+
key = normalise_key(match[:key])
|
|
119
|
+
value = match[:value].strip
|
|
120
|
+
next if key.empty? || blank?(value)
|
|
121
|
+
|
|
122
|
+
(pairs[key] ||= []) << value
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def comment?(line)
|
|
127
|
+
Availability::Patterns::COMMENT_PREFIXES.any? { |prefix| line.start_with?(prefix) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Strip alignment padding, collapse inner whitespace, downcase.
|
|
131
|
+
def normalise_key(key)
|
|
132
|
+
key.strip.sub(/[\s.·]+\z/, "").gsub(/\s+/, " ").downcase
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def first(pairs, keys)
|
|
136
|
+
keys.each do |key|
|
|
137
|
+
values = pairs[key]
|
|
138
|
+
return values.first if values && !values.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def all(pairs, keys)
|
|
145
|
+
keys.flat_map { |key| pairs[key] || [] }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def flatten(pairs)
|
|
149
|
+
pairs.transform_values { |values| values.length == 1 ? values.first : values }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def extract_contacts(pairs)
|
|
153
|
+
CONTACT_ROLES.each_with_object({}) do |(role, prefixes), contacts|
|
|
154
|
+
details = contact_details(pairs, prefixes)
|
|
155
|
+
contacts[role] = details unless details.empty?
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def contact_details(pairs, prefixes)
|
|
160
|
+
CONTACT_ATTRIBUTES.each_with_object({}) do |(attribute, suffix), details|
|
|
161
|
+
keys = prefixes.map { |prefix| "#{prefix} #{suffix}" }
|
|
162
|
+
value = first(pairs, keys)
|
|
163
|
+
details[attribute] = value unless value.nil?
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module MonoVM
|
|
6
|
+
module Whois
|
|
7
|
+
module Parser
|
|
8
|
+
# Parses an RDAP domain object (RFC 9083).
|
|
9
|
+
#
|
|
10
|
+
# RDAP is where structured data was the point, so this parser reads fields
|
|
11
|
+
# instead of guessing at prose. Two parts of the format still need translating:
|
|
12
|
+
# dates live in an +events+ array keyed by +eventAction+ rather than in named
|
|
13
|
+
# fields, and contacts are jCard/vCard arrays (RFC 7095) — a nested array of
|
|
14
|
+
# +[name, params, type, value]+ tuples that has to be walked to find an email.
|
|
15
|
+
class RdapJson < Base
|
|
16
|
+
# eventAction values mapped to the record fields they populate.
|
|
17
|
+
EVENTS = {
|
|
18
|
+
"registration" => :created_on,
|
|
19
|
+
"last changed" => :updated_on,
|
|
20
|
+
"last update of rdap database" => nil, # about the database, not the domain
|
|
21
|
+
"expiration" => :expires_on
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
ROLES = {
|
|
25
|
+
"registrant" => :registrant,
|
|
26
|
+
"administrative" => :admin,
|
|
27
|
+
"technical" => :tech,
|
|
28
|
+
"billing" => :billing,
|
|
29
|
+
"registrar" => :registrar
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# jCard property name => the record field it populates. +adr+ is absent because
|
|
33
|
+
# its value is a structured array rather than a plain string.
|
|
34
|
+
VCARD_FIELDS = { "fn" => :fn, "org" => :org, "email" => :email, "tel" => :tel }.freeze
|
|
35
|
+
|
|
36
|
+
def applicable?(response)
|
|
37
|
+
document = response.json
|
|
38
|
+
return false if document.nil?
|
|
39
|
+
|
|
40
|
+
document.key?("objectClassName") || document.key?("ldhName") ||
|
|
41
|
+
document.key?("rdapConformance")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parse(response)
|
|
45
|
+
document = response.json || {}
|
|
46
|
+
events = extract_events(document)
|
|
47
|
+
entities = extract_entities(document)
|
|
48
|
+
registrar = entities[:registrar] || {}
|
|
49
|
+
|
|
50
|
+
Record.new(
|
|
51
|
+
domain: document["unicodeName"] || document["ldhName"],
|
|
52
|
+
registry_id: document["handle"],
|
|
53
|
+
registrar: registrar[:organization] || registrar[:name],
|
|
54
|
+
registrar_iana_id: registrar[:iana_id],
|
|
55
|
+
registrant: entities.dig(:registrant, :organization) || entities.dig(:registrant, :name),
|
|
56
|
+
statuses: Array(document["status"]),
|
|
57
|
+
nameservers: extract_nameservers(document),
|
|
58
|
+
created_on: events[:created_on],
|
|
59
|
+
updated_on: events[:updated_on],
|
|
60
|
+
expires_on: events[:expires_on],
|
|
61
|
+
dnssec: extract_dnssec(document),
|
|
62
|
+
contacts: entities.except(:registrar),
|
|
63
|
+
fields: document,
|
|
64
|
+
source: name
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def extract_events(document)
|
|
71
|
+
Array(document["events"]).each_with_object({}) do |event, dates|
|
|
72
|
+
next unless event.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
field = EVENTS[event["eventAction"].to_s.downcase]
|
|
75
|
+
next if field.nil?
|
|
76
|
+
|
|
77
|
+
dates[field] ||= parse_time(event["eventDate"])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_nameservers(document)
|
|
82
|
+
Array(document["nameservers"]).filter_map do |nameserver|
|
|
83
|
+
next unless nameserver.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
nameserver["unicodeName"] || nameserver["ldhName"]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# +secureDNS.delegationSigned+ is the authoritative flag; report it in the
|
|
90
|
+
# same vocabulary the WHOIS parsers use so callers need not special-case RDAP.
|
|
91
|
+
def extract_dnssec(document)
|
|
92
|
+
secure = document["secureDNS"]
|
|
93
|
+
return nil unless secure.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
signed = secure["delegationSigned"]
|
|
96
|
+
return nil if signed.nil?
|
|
97
|
+
|
|
98
|
+
signed ? "signedDelegation" : "unsigned"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_entities(document)
|
|
102
|
+
Array(document["entities"]).each_with_object({}) do |entity, contacts|
|
|
103
|
+
next unless entity.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
Array(entity["roles"]).each do |raw_role|
|
|
106
|
+
role = ROLES[raw_role.to_s.downcase]
|
|
107
|
+
next if role.nil?
|
|
108
|
+
|
|
109
|
+
contacts[role] ||= entity_details(entity)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def entity_details(entity)
|
|
115
|
+
card = parse_vcard(entity["vcardArray"])
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
handle: entity["handle"],
|
|
119
|
+
name: card[:fn],
|
|
120
|
+
organization: card[:org],
|
|
121
|
+
email: card[:email],
|
|
122
|
+
phone: card[:tel],
|
|
123
|
+
country: card[:country],
|
|
124
|
+
iana_id: public_id(entity, "IANA Registrar ID")
|
|
125
|
+
}.compact
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A jCard is +["vcard", [[name, params, type, value], ...]]+ (RFC 7095).
|
|
129
|
+
# Addresses carry a structured value whose seventh element is the country.
|
|
130
|
+
def parse_vcard(vcard_array)
|
|
131
|
+
entries = vcard_array.is_a?(Array) ? vcard_array[1] : nil
|
|
132
|
+
return {} unless entries.is_a?(Array)
|
|
133
|
+
|
|
134
|
+
entries.each_with_object({}) do |entry, card|
|
|
135
|
+
next unless entry.is_a?(Array) && entry.length >= 4
|
|
136
|
+
|
|
137
|
+
key = entry[0].to_s.downcase
|
|
138
|
+
field = VCARD_FIELDS[key]
|
|
139
|
+
|
|
140
|
+
if field
|
|
141
|
+
card[field] ||= field == :org ? flatten_value(entry[3]) : entry[3].to_s
|
|
142
|
+
elsif key == "adr"
|
|
143
|
+
card[:country] ||= country_from(entry[3])
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def flatten_value(value)
|
|
149
|
+
value.is_a?(Array) ? value.flatten.compact.reject(&:empty?).first.to_s : value.to_s
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def country_from(value)
|
|
153
|
+
return nil unless value.is_a?(Array)
|
|
154
|
+
|
|
155
|
+
country = value[6]
|
|
156
|
+
country.to_s.strip.empty? ? nil : country.to_s.strip
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def public_id(entity, type)
|
|
160
|
+
Array(entity["publicIds"]).each do |public_id|
|
|
161
|
+
next unless public_id.is_a?(Hash)
|
|
162
|
+
return public_id["identifier"].to_s if public_id["type"].to_s.casecmp?(type)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|