monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation

monovm-whois-rust

crates.io docs.rs license

Domain WHOIS and RDAP lookups for Rust, with availability detection you can audit.

A ground-up Rust design: RDAP, structured record parsing, referral chasing, rate limiting, caching, and verdicts that explain themselves.

use monovm_whois::WhoisClient;

let client = WhoisClient::new()?;
let lookup = client.lookup("example.com")?;

println!("{} is {}", lookup.domain, lookup.availability());

if let Some(record) = &lookup.record {
    println!("registrar: {:?}", record.registrar);
    println!("expires:   {:?}", record.expires);
    println!("locked:    {}", record.is_transfer_locked());
}
# Ok::<(), monovm_whois::Error>(())

Install

[dependencies]
monovm-whois-rust = "1"

The library is imported as monovm_whois. Async instead of blocking:

monovm-whois-rust = { version = "1", default-features = false, features = ["async", "rdap", "parser"] }

The command line tool (installs the monovm-whois binary):

cargo install monovm-whois-rust --features cli

The problem this crate is about

WHOIS has no status codes. A registry saying this domain is free, one saying you are querying too fast, and one saying I do not serve that suffix all send prose over the same socket — and every one of them can contain the word "available".

Libraries in this space overwhelmingly resolve that ambiguity the same way: anything not recognisably a record is treated as availability. The consequence is that a rate-limited registry reports its entire zone as free to register, and a stale server mapping that lands a query on a regional IP registry does the same.

This crate never reports an unanswered query as an answer:

use monovm_whois::{Error, Refusal};

# fn demo(client: &monovm_whois::WhoisClient) {
match client.lookup("example.com") {
    Ok(lookup)                                                  => { /* a real verdict */ }
    Err(Error::Refused { reason: Refusal::RateLimited, .. })     => { /* back off */ }
    Err(Error::Inconclusive { detail, .. })                      => { /* unparseable */ }
    Err(other)                                                   => { /* network, input, … */ }
}
# }

There is deliberately no Availability::Unknown. An uncertain answer that renders as "available" is the one outcome a caller must not be handed.

What you get

Coverage 873 curated suffixes plus IANA's RDAP bootstrap registry — over 1600 in total
Two protocols Port 43 (RFC 3912) and RDAP (RFC 7482/9082/9083), with automatic fallback
Structured records Typed dates, EPP statuses, name servers, DNSSEC, contacts — not raw text
Referral chasing Thin registries point at the registrar; this follows the pointer and merges both records
Auditable verdicts Every answer names the rule that produced it, with a confidence level
Rate limiting Per-host pacing, because registries punish speed with silence
Caching Pluggable, with a bundled in-memory TTL + LRU implementation
Retries Exponential backoff, applied only to failures worth retrying
Both runtimes WhoisClient and AsyncWhoisClient
No unsafe #![forbid(unsafe_code)]

Examples

Bulk availability, and bare names

A name with no suffix is checked under a list of popular ones — the "is this brand free?" search:

use monovm_whois::{Checker, WhoisClient};

let checker = Checker::new(WhoisClient::new()?)
    .with_popular_tlds(["com", "net", "io", "co.uk"])?;

let report = checker.check(["monovm", "example.com"]);

println!("{report}");
println!("free: {:?}", report.available());

// Failures are entries, not silence: a lookup that could not be answered is
// reported as such rather than folded into "taken".
for (domain, error) in report.failures() {
    eprintln!("{domain}: {error}");
}
# Ok::<(), monovm_whois::Error>(())

Concurrent lookups

use monovm_whois::AsyncWhoisClient;

# async fn demo() -> Result<(), monovm_whois::Error> {
let client = AsyncWhoisClient::builder()
    .concurrency(32)
    .build()?;

for (domain, outcome) in client.lookup_many(["example.com", "example.net"]).await {
    match outcome {
        Ok(lookup) => println!("{domain}: {}", lookup.availability()),
        Err(error) => eprintln!("{domain}: {error}"),
    }
}
# Ok(())
# }

Why did it say that?

# fn demo(client: &monovm_whois::WhoisClient) -> Result<(), monovm_whois::Error> {
println!("{}", client.explain("example.com")?);
# Ok(())
# }
example.com — registered (high confidence, registered: status field matched "status[\s._\-]*:\s*active")
consulted: whois.verisign-grs.com, whois.example-registrar.com
verdict: registered (high confidence, registered: status field matched …)
response: 2214 bytes
  wrong-server     abstained
  refusal          abstained
  rdap             abstained
  registry-marker  abstained
  withheld         abstained
  registered       registered [high] status field matched …
  not-found        abstained
  tld-pattern      abstained
  recordless       abstained

Configuring it

use std::time::Duration;
use monovm_whois::client::{Preference, ReferralPolicy};
use monovm_whois::WhoisClient;

let client = WhoisClient::builder()
    // RDAP first: its 404 makes availability a fact rather than an inference.
    .prefer(Preference::Rdap)
    .referrals(ReferralPolicy::eager(2))
    .memory_cache(Duration::from_secs(600))
    .throttle_per_host(Duration::from_millis(500))
    .connect_timeout(Duration::from_secs(3))
    .build()?;
# Ok::<(), monovm_whois::Error>(())

Architecture

Six layers, each with one job and no knowledge of the others. Every one is a trait with a bundled implementation, so any of them can be replaced without forking:

              ┌──────────────────────────────────────────┐
              │  client — composes the lookup sequence   │
              └────┬─────────┬──────────┬────────┬───────┘
                   │         │          │        │
        ┌──────────▼──┐  ┌───▼──────┐ ┌─▼─────┐ ┌▼────────┐
        │  registry   │  │transport │ │detect │ │ parser  │
        │ who serves  │  │ the only │ │ what  │ │ record  │
        │ this suffix │  │   I/O    │ │it said│ │ as data │
        └──────┬──────┘  └────┬─────┘ └───────┘ └─────────┘
               │              │
        RegistryProvider   Transport ← cache, retry, throttle (decorators)
               │
        ┌──────▼──────────────────────────────────────────┐
        │  domain — validated values: DomainName, Tld     │
        └─────────────────────────────────────────────────┘

Patterns used, and what each one buys:

Pattern Where Why
Chain of Responsibility detect::AvailabilityRule Detection is ~10 competing heuristics whose order is the design. A chain makes the priority explicit and each rule independently testable.
Decorator RetryTransport, ThrottleTransport, CachingTransport Retrying, pacing and caching are orthogonal to talking. Composing them lets a caller order them meaningfully — caching outside throttling serves repeats without waiting.
Strategy RecordParser, Transport, RegistryProvider One interface per varying algorithm, so adding a protocol or a record format is a new type rather than a new match arm.
Composite LayeredRegistry, Router, CompositeParser Several providers behave as one, so the client holds a single collaborator regardless of how many sources back it.
Builder WhoisClientBuilder, RegistryBuilder Both have many optional fields with sensible defaults; a builder keeps the common case to one line and adding a field non-breaking.
Value Object DomainName, Tld, Availability Validated once at the boundary. A DomainName that exists is queryable, so no layer below re-checks.
Null Object NullCache "No caching" is a value, not a branch, so the caching layer is always present and always called the same way.
Template Method RegistryProvider::resolve Suffix resolution is one algorithm over a primitive each provider supplies — doing it in both places would make the result depend on which provider answered first.

SOLID, concretely:

  • SWhois43Transport opens sockets and nothing else; deciding what the bytes mean is detect's job, and parsing them is parser's.
  • O — a new detection rule, transport, cache or registry source is a new type. No existing file changes.
  • L — every Transport is substitutable, MockTransport included; that is what makes the test suite run without a network.
  • IResponseCache has three methods, RecordParser two. Nothing implements what it does not need.
  • DWhoisClient depends on dyn RegistryProvider / dyn Transport, never on a concrete type.

Features

Feature Default Gives you
blocking WhoisClient, synchronous transports
rdap RDAP over HTTPS, typed RFC 9083 model
parser WhoisRecord and record parsing
async AsyncWhoisClient, Tokio transports
iana-bootstrap Refresh the RDAP registry from IANA at runtime
cli The monovm-whois binary
mock MockTransport, for your own tests

Command line

monovm-whois example.com                  # availability
monovm-whois monovm --tlds com,net,io     # a bare name across several suffixes
monovm-whois example.com --record         # the parsed registration
monovm-whois example.com --raw            # the server's own text
monovm-whois example.com --explain        # why the verdict is what it is
monovm-whois example.com --json           # machine-readable
monovm-whois --list-tlds                  # every suffix this build supports

Exit status reflects whether the questions were answered. A domain that turns out to be taken exits zero; a registry that refused to answer exits non-zero.

Registry data

Two bundled sources, layered:

  • data/registries.json — 289 curated registry definitions covering 873 suffixes: WHOIS hosts, the exact wording each server uses for an unregistered name, and per-registry quirks. Regenerate from an upstream definition list with python data/build_registries.py path/to/dist.whois.json; that script also applies this crate's corrections to hosts such a list has left behind and adds registries it never covered, so a regeneration does not lose them.
  • data/rdap-bootstrap.json — a snapshot of IANA's RDAP bootstrap registry, covering about 1200 suffixes.

Point at your own file, or stack it over the bundled data:

use monovm_whois::registry::{JsonRegistry, LayerStrategy, LayeredRegistry, default_provider};
use monovm_whois::WhoisClient;

let registry = LayeredRegistry::new(LayerStrategy::Override)
    .shared_layer(default_provider())
    .layer(JsonRegistry::from_path("my-overrides.json")?);

let client = WhoisClient::builder().registry(registry).build()?;
# Ok::<(), monovm_whois::Error>(())

With the iana-bootstrap feature, refresh the RDAP list from IANA rather than shipping a snapshot that ages.

Testing

The suite runs offline: MockTransport scripts responses, and fixtures hold real recorded records from a range of registries.

cargo test                          # offline, the default
cargo test --all-features
cargo test -- --ignored             # the live-network tests, opt-in

The network tests are #[ignore] on purpose. A test that fails because a registry is having a bad afternoon teaches nobody anything.

They are still worth running occasionally: survey_the_popular_suffixes sweeps 40 suffixes and prints which answered and how. Its last run answered 35 and got none of them wrong; the wordings it found that no table had — AFNIC's %% NOT FOUND, auDA's bare Available, JPRS's bracketed field keys — are in the CHANGELOG and pinned by fixtures.

License

MIT. See LICENSE.