Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

About

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

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages