monovm-whois-ruby
Domain WHOIS and RDAP lookups for Ruby, with availability detection that says "I don't know" instead of guessing.
No runtime dependencies.
require "monovm-whois-ruby"
MonoVM::Whois.available?("monovm.com") # => false
MonoVM::Whois.lookup("monovm.com").record.expires_on
MonoVM::Whois.whois(%w[monovm google.com])
Installation
gem "monovm-whois-ruby"
or
gem install monovm-whois-ruby
Requires Ruby 3.1 or newer.
Why the availability status has four values
Most WHOIS libraries answer "is this domain available?" with a boolean. That collapses two very different situations —
this domain is registered and I could not find out — into the same false.
The expensive failure is the other direction. A WHOIS server that is rate-limiting you, or that has retired port 43, or that answered with an HTML error page, sends back text containing none of the words that mean "registered". A detector built from heuristics falls through all of them and lands on its most permissive rule, which reports the domain as free. For a registrar that means putting a registered domain in a customer's shopping cart.
So a lookup here returns one of five statuses, and available means it was positively
established:
| Status | Meaning |
|---|---|
:available |
The registry said this name is not registered. |
:registered |
The name exists. |
:premium |
Unregistered, but reserved or premium-priced — not obtainable normally. |
:unknown |
No verdict. Rate limited, unreachable, unreadable. Ask again later. |
:invalid |
The input was not a usable domain, or its TLD has no known server. |
result = MonoVM::Whois.lookup("example.com")
result.status # => :registered
result.available? # => false
result.registered? # => true
result.unknown? # => false
result.conclusive? # => true
available? is safe to branch on. Treating !available? as "registered" is not —
that is what registered? is for.
Usage
One domain
result = MonoVM::Whois.lookup("monovm.com")
result.name # => "monovm.com"
result.tld # => ".com"
result.sld # => "monovm"
result.status # => :registered
result. # the raw registry response, verbatim
result.record.registrar # => "Example Registrar, LLC"
Many domains, and names without a TLD
MonoVM::Whois.whois("monovm.com")
# => {"monovm.com" => :registered}
MonoVM::Whois.whois(%w[monovm google.com])
# => {"monovm.com" => :registered,
# "monovm.net" => :registered,
# "monovm.org" => :available,
# "monovm.info" => :available,
# "google.com" => :registered}
MonoVM::Whois.whois("monovm", popular_tlds: %w[.io .dev])
# => {"monovm.io" => :registered, "monovm.dev" => :available}
Bulk checks run concurrently (8 threads by default) and are throttled per host, so a
list of 500 .com names does not get your IP rate-limited by Verisign.
Duplicates, mixed case, URLs and trailing dots all collapse to one lookup:
"EXAMPLE.COM", "https://example.com/path" and "example.com." are the same name.
The parsed record
record = MonoVM::Whois.lookup("example.com").record
record.registrar # => "Example Registrar, LLC"
record.created_on # => 1995-08-14 04:00:00 UTC
record.expires_on # => 2027-08-13 04:00:00 UTC
record.days_until_expiry # => 372
record.nameservers # => ["ns1.example.com", "ns2.example.com"]
record.statuses # => ["clientTransferProhibited"]
record.registrant # => nil when the registry redacts it
record.dnssec? # => false
record.transfer_prohibited? # => true
record.expiring? # => false
record.contacts[:admin] # => {name: ..., email: ...}
record["Registry Domain ID"] # any raw field, by its original key
Post-GDPR placeholders (REDACTED FOR PRIVACY, Data Protected, …) are reported as
nil rather than as a registrant literally named "REDACTED FOR PRIVACY".
Why a verdict came out that way
MonoVM::Whois.explain("example.com")
# => {domain: "example.com",
# status: :registered,
# reason: "RDAP returned a domain object",
# trace: [{rule: "server_refusal", matched: false},
# {rule: "wrong_registry", matched: false},
# {rule: "rdap_object", matched: true, status: :registered,
# evidence: "example.com"}],
# endpoint: "https://rdap.verisign.com/com/v1/domain/"}
Every rule that was consulted is in the trace, in order, with the one that decided and the text it matched. This is the first thing to reach for when a classification looks wrong.
Single-domain handler
WhoisHandler wraps one lookup in an object, with camelCase aliases for code
being migrated from camelCase WHOIS APIs:
handler = MonoVM::Whois::WhoisHandler.whois("monovm.com")
handler.available? # also handler.isAvailable
handler.valid? # also handler.isValid
handler. # also handler.getWhoisMessage
handler.tld # also handler.getTld
handler.availability_details # also handler.getAvailabilityDetails
The four-state verdict described above applies here too: handler.unknown? is a
real answer, distinct from both available? and registered?.
Command line
$ monovm-whois monovm.com google.com
monovm.com registered
google.com registered
$ monovm-whois monovm --tlds .io,.dev --details
$ monovm-whois example.com --json
$ monovm-whois example.com --prefer whois --timeout 5
$ monovm-whois --tld-count
2043 TLDs supported
Exit code is 0 when every name got a real answer and 1 when any came back unknown or
invalid — so a script can tell "definitely free" from "could not find out".
Configuration
MonoVM::Whois.configure do |config|
config.prefer = :whois # port 43 before RDAP (default: :rdap)
config.follow_referrals = true # chase thin registries to the registrar
config.concurrency = 8 # threads for bulk checks
config.throttle_interval = 0.5 # minimum seconds between queries to one host
config.cache_ttl = 300 # in-process response cache
config.retry_attempts = 2 # retries for timeouts, never for refusals
config.verify_ssl = true
config.socket_read_timeout = 15
config.popular_tlds = %w[.com .net .org .info]
config.instrumentation = ->(event) { Rails.logger.info(event) }
end
Architecture
Five collaborators, each replaceable, wired together by Client:
Client
├── Registry::ServerRegistry which server serves this TLD, and where the name splits
├── Transport::Factory how to talk to it (Strategy + Decorator)
├── Availability::Analyzer what the answer means (Chain of Responsibility)
├── Parser::Selector what the record says (Adapter)
└── Referral::Follower thin-registry second hop
Client owns the sequence and none of the policy. Every collaborator arrives by
constructor injection, which is why the whole test suite runs offline against a
one-method fake transport.
Detection is a chain of rules
Each rule answers one question and either returns a Verdict or nil for "not mine,
ask the next one". Two invariants hold the order together:
- Rules that recognise a non-answer run first, so nothing reaching the permissive rules could have been a refusal or a wrong-server reply.
- Every rule concluding
:registeredruns before every rule concluding:available, so when signals conflict the safe one wins.
| # | Rule | Verdict |
|---|---|---|
| 1 | server_refusal — rate limit, blocked client, retired port 43, HTTP error |
:unknown |
| 2 | wrong_registry — unsupported TLD, or an address registry's banner |
:unknown |
| 3 | rdap_object — structured JSON: objectClassName vs errorCode 404 |
:registered / :available |
| 4 | premium_name |
:premium |
| 5 | explicit_unavailability — general and per-TLD "registered" wording |
:registered |
| 6 | registration_fields — three or more record fields present |
:registered |
| 7 | registry_marker — the TLD's configured not-found string |
:available |
| 8 | availability_keywords — multilingual not-found phrases |
:available |
| 9 | no_match — the whitespace-tolerant regexp forms |
:available |
| 10 | tld_specific — per-TLD availability wording |
:available |
| 11 | status_field — an explicit status: available |
:available |
| 12 | recordless — no record and no refusal; opt-in per TLD |
:available |
| — | nothing matched | :unknown |
Adding support for a registry that invents new wording means one small object:
class AcmeRule < MonoVM::Whois::Availability::Rule
def call(context)
return nil unless context.lower.include?("acme says this name is free")
available(reason: "ACME wording")
end
end
MonoVM::Whois.configure do |config|
config.rules.insert_before("registry_marker", AcmeRule.new)
end
RuleSet also supports prepend, append, insert_after, replace and remove.
RDAP first
For any TLD with an RDAP endpoint the client queries RDAP before port 43. RDAP answers
in structured JSON — a registered domain is an object with an objectClassName, an
unregistered one is an error document with errorCode 404 — so availability is read
rather than inferred from prose. Port 43 is the fallback, used whenever a TLD has no
RDAP endpoint or its RDAP endpoint fails.
TLD coverage comes from two bundled data files:
data/whois_servers.json— a curated port 43 server list, 872 TLDs, with the registry's not-found marker per TLD.data/rdap_bootstrap.json— a snapshot of the IANA RDAP bootstrap registry (RFC 7484), ~1,200 TLDs. Refresh withrake data:refresh_rdap.
Definitions merge, so a TLD can get its port 43 host from one file and its RDAP URL from the other. A deployment can correct a stale entry without waiting for a release:
export MONOVM_WHOIS_DEFINITIONS=/etc/monovm/whois-overrides.json
[
{
"extensions": ".example,.test",
"uri": "socket://whois.example.test",
"available": "Domain not found",
"rdap": "https://rdap.example.test/domain/"
}
]
Referral following
Thin registries hold almost nothing. Query .com and Verisign returns a name, a
status, nameservers and a pointer: Registrar WHOIS Server:. The registrant and often
the accurate expiry date only exist on that second server, so the client follows the
pointer one hop.
A referral enriches the record and never the verdict. The registry is authoritative about whether a name exists, and a registrar's server that is down must not be able to turn a registered domain into an available one.
Punycode
Internationalised names are converted with an RFC 3492 implementation in
MonoVM::Whois::Punycode, verified against the RFC's own test vectors and the
published ACE forms of the IDN ccTLDs. It lives here rather than in a dependency to
keep the gem dependency-free.
Registries are queried in Punycode by default, because Verisign answers "No match" to
a UTF-8 query — which would read as availability. DENIC is the documented exception and
is sent the Unicode form over port 43 (config.unicode_query_tlds).
Fail-safe classifications
Situations that a permissive heuristic reads as "available", and what this gem reports instead — every choice points the same way, refusing to guess:
| Situation | Permissive heuristic | This gem |
|---|---|---|
| Rate-limit notice | available |
:unknown |
| Client blocked / port 43 retired | available |
:unknown |
| HTTP 403/429/5xx from RDAP | available |
:unknown |
| Empty response | available |
:unknown |
| Address registry reached by mistake | available |
:unknown |
| Fewer than 2 registration fields | available |
:unknown unless the TLD opts in |
DENIC Status: invalid |
available |
:registered |
| Premium/reserved name | available |
:premium |
Dot-padded keys (status....: Registered) |
available |
:registered |
| Registry restriction notice | available |
:registered |
Development
bundle install
bundle exec rspec # the offline suite
bundle exec rubocop
rake # both
rake spec_network # live registry lookups, opt-in
rake data:refresh_rdap # re-snapshot the IANA bootstrap
COVERAGE=1 bundle exec rspec
The offline suite never touches the network: HTTP is blocked by WebMock, and the socket transport is exercised against a loopback server the spec starts itself.
License
MIT. See LICENSE.