GSMLG.Whois

A fast, production-ready Elixir library for performing WHOIS lookups on domain names, IP addresses, and AS numbers with intelligent caching and telemetry support.

Features

What is WHOIS?

WHOIS is a protocol for querying databases that store information about registered internet resources:

Installation

Add gsmlg_whois to your list of dependencies in mix.exs:

def deps do
  [
    {:gsmlg_whois, "~> 0.5.2"}
  ]
end

Quick Start

Domain Lookup

# Lookup domain registration information
{:ok, results} = GSMLG.Whois.lookup_raw("google.com")

for {server, whois_data} <- results do
  IO.puts("=== #{server} ===")
  IO.puts(whois_data)
end

IP Address Lookup

# Lookup IP allocation and network information
{:ok, results} = GSMLG.Whois.lookup_raw("8.8.8.8")

# Results contain information from IANA and regional registry
[{_iana_server, iana_data}, {rir_server, rir_data}] = results

IO.puts("Regional Internet Registry: #{rir_server}")
IO.puts(rir_data)

AS Number Lookup

# Lookup autonomous system information
{:ok, results} = GSMLG.Whois.lookup_raw("13335")

for {server, whois_data} <- results do
  if whois_data =~ "Cloudflare" do
    IO.puts("Found Cloudflare AS information")
    IO.puts(whois_data)
  end
end

Basic Usage

Simple Lookup

# Returns {:ok, results} or {:error, reason}
case GSMLG.Whois.lookup_raw("example.com") do
  {:ok, results} ->
    [{server, data} | _rest] = results
    IO.puts("Retrieved from #{server}")

  {:error, :timeout} ->
    IO.puts("Request timed out")

  {:error, reason} ->
    IO.puts("Lookup failed: #{inspect(reason)}")
end

Custom WHOIS Server

# Query a specific WHOIS server directly
{:ok, results} = GSMLG.Whois.lookup_raw("google.com",
  server: "whois.markmonitor.com"
)

# Or use a Server struct
server = %GSMLG.Whois.Server{host: "whois.markmonitor.com", port: 43}
{:ok, results} = GSMLG.Whois.lookup_raw("google.com", server: server)

Caching

GSMLG.Whois includes intelligent caching to improve performance and reduce load on WHOIS servers. The default ETS backend needs no additional package and does not install Concord, ExTurso, Ra, Libcluster, or Postgrex. The optional PostgreSQL backend requires the host application to add {:postgrex, "~> 0.21"} and configure a connection pool. To use the Concord backend, add {:concord, "~> 2.0"} to the host application's dependencies and ensure its application is started before selecting GSMLG.Whois.Cache.Concord. ExTurso's compatible 0.3.x constraint applies when Concord is selected; it is also optional for ETS-only consumers.

Enabling Cache

# Start the cache in your application supervision tree
children = [
  GSMLG.Whois.Cache
]

Supervisor.start_link(children, strategy: :one_for_one)

Or add to your application.ex:

def start(_type, _args) do
  children = [
    GSMLG.Whois.Cache,
    # ... other children
  ]

  Supervisor.start_link(children, strategy: :one_for_one)
end

Cache Configuration

# Configure TTL in config/config.exs
config :gsmlg_whois,
  cache_ttl: :timer.hours(24),        # Default: 24 hours for domains
  cache_ttl_ip: :timer.hours(1),      # Default: 1 hour for IPs
  cache_ttl_asn: :timer.hours(1)      # Default: 1 hour for AS numbers

Bypassing Cache

# Force fresh lookup, bypassing cache
{:ok, results} = GSMLG.Whois.lookup_raw("example.com", cache: false)

Cache Statistics

# Get cache statistics
stats = GSMLG.Whois.Cache.stats()
IO.inspect(stats)
# => %{size: 42, hits: 150, misses: 23, hit_rate: 0.867}

# Clear the cache
GSMLG.Whois.Cache.clear()

Parsing WHOIS Output

WHOIS responses vary significantly between servers and have no standard format. Here are common patterns for extracting information:

Domain Information

{:ok, results} = GSMLG.Whois.lookup_raw("example.com")
{_server, whois_data} = List.last(results)  # Get final authoritative response

# Extract registrar
registrar = whois_data
  |> String.split("\n")
  |> Enum.find_value(fn line ->
    case line do
      "Registrar:" <> value -> String.trim(value)
      "registrar:" <> value -> String.trim(value)
      _ -> nil
    end
  end)

# Extract creation date
creation_date = whois_data
  |> String.split("\n")
  |> Enum.find_value(fn line ->
    cond do
      String.contains?(line, "Creation Date:") ->
        line |> String.split(":") |> Enum.at(1) |> String.trim()
      String.contains?(line, "created:") ->
        line |> String.split(":") |> Enum.at(1) |> String.trim()
      true -> nil
    end
  end)

# Extract nameservers
nameservers = whois_data
  |> String.split("\n")
  |> Enum.filter(&String.contains?(&1, "Name Server:"))
  |> Enum.map(fn line ->
    line |> String.split(":") |> Enum.at(1) |> String.trim() |> String.downcase()
  end)

IO.inspect(%{
  registrar: registrar,
  creation_date: creation_date,
  nameservers: nameservers
})

IP Network Information

{:ok, results} = GSMLG.Whois.lookup_raw("8.8.8.8")
{_server, whois_data} = List.last(results)

# Extract network range
network = Regex.run(~r/NetRange:\s*(.+)/, whois_data, capture: :all_but_first)
  |> case do
    [range] -> range
    _ -> nil
  end

# Extract organization
org_name = Regex.run(~r/OrgName:\s*(.+)/, whois_data, capture: :all_but_first)
  |> case do
    [name] -> name
    _ -> nil
  end

# Extract abuse contact
abuse_email = Regex.run(~r/OrgAbuseEmail:\s*(.+)/, whois_data, capture: :all_but_first)
  |> case do
    [email] -> email
    _ -> nil
  end

IO.inspect(%{
  network: network,
  organization: org_name,
  abuse_email: abuse_email
})

Helper Module for Parsing

defmodule MyApp.WhoisParser do
  def parse_domain(whois_data) do
    lines = String.split(whois_data, "\n")

    %{
      registrar: extract_field(lines, ["Registrar:", "registrar:"]),
      creation_date: extract_field(lines, ["Creation Date:", "created:"]),
      expiry_date: extract_field(lines, ["Expiry Date:", "Registry Expiry Date:"]),
      nameservers: extract_nameservers(lines),
      status: extract_field(lines, ["Domain Status:", "status:"])
    }
  end

  defp extract_field(lines, patterns) do
    Enum.find_value(lines, fn line ->
      Enum.find_value(patterns, fn pattern ->
        if String.starts_with?(line, pattern) do
          line |> String.split(":", parts: 2) |> Enum.at(1) |> String.trim()
        end
      end)
    end)
  end

  defp extract_nameservers(lines) do
    lines
    |> Enum.filter(&(String.contains?(&1, "Name Server:") or String.contains?(&1, "nserver:")))
    |> Enum.map(fn line ->
      line |> String.split(":", parts: 2) |> Enum.at(1) |> String.trim() |> String.downcase()
    end)
  end
end

# Usage
{:ok, results} = GSMLG.Whois.lookup_raw("example.com")
{_server, whois_data} = List.last(results)
parsed = MyApp.WhoisParser.parse_domain(whois_data)

Telemetry Events

GSMLG.Whois emits telemetry events for monitoring and debugging:

Available Events

Event Metadata

Lookup events include %{query: "example.com", type: :domain}. The :stop event's measurements include duration in native time units; :exception metadata additionally includes kind, reason, and stacktrace. Cache events include the same query/type fields, with :hit or :miss in the event name. A normal {:error, :timeout} return emits :stop, while :exception is reserved for exceptions, throws, and exits.

Setting Up Telemetry Handler

defmodule MyApp.TelemetryHandler do
  require Logger

  def handle_event([:gsmlg, :whois, :lookup, :start], _measurements, metadata, _config) do
    Logger.info("Starting WHOIS lookup for: #{metadata.query}")
  end

  def handle_event([:gsmlg, :whois, :lookup, :stop], measurements, metadata, _config) do
    duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
    Logger.info("WHOIS lookup returned in #{duration_ms}ms: #{metadata.query}")
  end

  def handle_event([:gsmlg, :whois, :lookup, :exception], measurements, metadata, _config) do
    Logger.error("WHOIS lookup failed for #{metadata.query}: #{inspect(metadata.reason)}")
  end
end

# Attach the handler
:telemetry.attach_many(
  "whois-telemetry-handler",
  [
    [:gsmlg, :whois, :lookup, :start],
    [:gsmlg, :whois, :lookup, :stop],
    [:gsmlg, :whois, :lookup, :exception]
  ],
  &MyApp.TelemetryHandler.handle_event/4,
  nil
)

Integration with GSMLG.Telemetry

If using the GSMLG umbrella project's telemetry system:

# Events are automatically logged to configured backends
# (console, file, CloudWatch, etc.)

# View metrics
GSMLG.Telemetry.Metrics.get_summary()

# See WHOIS-specific metrics
GSMLG.Telemetry.Metrics.get_metrics([:gsmlg, :whois, :lookup, :stop])

Error Handling

Common Errors

case GSMLG.Whois.lookup_raw(query) do
  {:ok, results} ->
    # Success

  {:error, :timeout} ->
    # Network timeout (30 seconds default)

  {:error, :closed} ->
    # Connection closed by server

  {:error, :econnrefused} ->
    # Connection refused (server down or firewalled)

  {:error, :nxdomain} ->
    # DNS resolution failed

  {:error, reason} ->
    # Other error
    Logger.error("WHOIS lookup failed: #{inspect(reason)}")
end

Timeout Configuration

WHOIS uses a finite total deadline, including native DNS resolution, TCP connection, sending the query, every response read, and all referrals. The default is 30,000 milliseconds. New data and referrals do not restart the budget. Expiry returns {:error, :timeout} even if a previous server returned a partial record. Other referral connection errors retain the previous raw records. Set a non-negative integer timeout; zero expires immediately, and :infinity is rejected. Cancelling a caller task closes its linked lookup worker and the worker-owned sockets.

# Configure timeout in config/config.exs
config :gsmlg_whois,
  timeout: 60_000  # 60 seconds

# Or pass per-request
{:ok, results} = GSMLG.Whois.lookup_raw("example.com", timeout: 10_000)

Production Best Practices

1. Always Use Caching

# In your application.ex
def start(_type, _args) do
  children = [
    GSMLG.Whois.Cache,  # Start cache first
    # ... other children
  ]

  Supervisor.start_link(children, strategy: :one_for_one)
end

2. Handle Timeouts Gracefully

defmodule MyApp.Whois do
  def lookup_with_fallback(query) do
    case GSMLG.Whois.lookup_raw(query) do
      {:ok, results} ->
        {:ok, results}

      {:error, :timeout} ->
        # Return cached result even if expired, or use alternative source
        case GSMLG.Whois.Cache.get(query) do
          {:ok, stale_result} ->
            Logger.warn("Using stale cache for #{query}")
            {:ok, stale_result}
          _ ->
            {:error, :timeout}
        end

      error ->
        error
    end
  end
end

3. Implement Rate Limiting

defmodule MyApp.RateLimitedWhois do
  use GenServer

  @rate_limit 1  # 1 request per second

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def lookup(query) do
    GenServer.call(__MODULE__, {:lookup, query}, :timer.minutes(2))
  end

  def init(_opts) do
    {:ok, %{last_request: 0}}
  end

  def handle_call({:lookup, query}, _from, state) do
    # Ensure at least 1 second between requests
    now = System.monotonic_time(:millisecond)
    delay = max(0, @rate_limit * 1000 - (now - state.last_request))

    if delay > 0, do: Process.sleep(delay)

    result = GSMLG.Whois.lookup_raw(query)
    {:reply, result, %{state | last_request: System.monotonic_time(:millisecond)}}
  end
end

4. Monitor with Telemetry

# Track slow queries
:telemetry.attach(
  "slow-whois-queries",
  [:gsmlg, :whois, :lookup, :stop],
  fn _event, measurements, metadata, _config ->
    duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)

    if duration_ms > 5_000 and not metadata.cache_hit do
      Logger.warn("Slow WHOIS query: #{metadata.query} took #{duration_ms}ms from #{metadata.server}")
    end
  end,
  nil
)

5. Batch Lookups with Task.async_stream

defmodule MyApp.BatchWhois do
  def lookup_batch(queries) do
    queries
    |> Task.async_stream(
      &GSMLG.Whois.lookup_raw/1,
      max_concurrency: 5,      # Don't overwhelm WHOIS servers
      timeout: :timer.minutes(2),
      on_timeout: :kill_task
    )
    |> Enum.map(fn
      {:ok, {:ok, result}} -> {:ok, result}
      {:ok, {:error, reason}} -> {:error, reason}
      {:exit, :timeout} -> {:error, :timeout}
    end)
  end
end

# Usage
queries = ["google.com", "cloudflare.com", "github.com"]
results = MyApp.BatchWhois.lookup_batch(queries)

Comparison with Alternatives

vs. External WHOIS APIs

GSMLG.Whois:

Commercial APIs (WhoisXML, etc.):

vs. System whois command

GSMLG.Whois:

System whois:

Common Use Cases

1. Domain Availability Checker

defmodule DomainChecker do
  def available?(domain) do
    case GSMLG.Whois.lookup_raw(domain) do
      {:ok, results} ->
        {_server, whois_data} = List.last(results)

        # Check for "No match" or similar indicators
        not_registered =
          String.contains?(whois_data, "No match") or
          String.contains?(whois_data, "NOT FOUND") or
          String.contains?(whois_data, "No Data Found")

        not_registered

      {:error, _} ->
        false
    end
  end
end

2. Bulk Domain Monitoring

defmodule DomainMonitor do
  def check_expiry(domains) do
    domains
    |> Enum.map(fn domain ->
      case GSMLG.Whois.lookup_raw(domain) do
        {:ok, results} ->
          {_server, whois_data} = List.last(results)
          expiry = extract_expiry_date(whois_data)

          {domain, expiry, days_until_expiry(expiry)}

        {:error, reason} ->
          {domain, nil, {:error, reason}}
      end
    end)
    |> Enum.filter(fn
      {_domain, _date, days} when is_integer(days) and days < 30 -> true
      _ -> false
    end)
  end

  defp days_until_expiry(nil), do: nil
  defp days_until_expiry(date_string) do
    # Parse date and calculate days
    # Implementation depends on date format
  end
end

3. IP Abuse Contact Lookup

defmodule AbuseContact do
  def find(ip_address) do
    case GSMLG.Whois.lookup_raw(ip_address) do
      {:ok, results} ->
        {_server, whois_data} = List.last(results)

        abuse_email =
          Regex.run(~r/abuse.*?:\s*([^\s@]+@[^\s]+)/i, whois_data, capture: :all_but_first)
          |> case do
            [email] -> email
            _ -> nil
          end

        {:ok, abuse_email}

      error ->
        error
    end
  end
end

Testing

Unit Tests

defmodule MyApp.WhoisTest do
  use ExUnit.Case

  test "lookup returns structured data" do
    {:ok, results} = GSMLG.Whois.lookup_raw("example.com")

    assert is_list(results)
    assert length(results) > 0

    for {server, whois_data} <- results do
      assert is_binary(server)
      assert is_binary(whois_data)
      assert String.length(whois_data) > 0
    end
  end
end

Live Tests (tagged)

@tag :live
test "lookup real domain" do
  {:ok, results} = GSMLG.Whois.lookup_raw("google.com")
  {_server, whois_data} = List.last(results)

  assert String.contains?(whois_data, "Google")
end

# Run only live tests:
# mix test --only live

Mocking for Tests

# test/support/whois_mock.ex
defmodule MyApp.WhoisMock do
  def lookup_raw("test.com", _opts \\ []) do
    {:ok, [
      {"whois.iana.org", "refer: whois.test.com"},
      {"whois.test.com", """
      Domain Name: TEST.COM
      Registrar: Test Registrar
      Creation Date: 2020-01-01T00:00:00Z
      """}
    ]}
  end

  def lookup_raw(_query, _opts), do: {:error, :not_found}
end

Troubleshooting

High Memory Usage

Cause: Cache growing too large Solution:

# Set maximum cache size in config
config :gsmlg_whois,
  cache_max_size: 10_000  # Limit to 10k entries

# Or periodically clear cache
GSMLG.Whois.Cache.clear()

Slow Lookups

Cause: Network latency or WHOIS server overload Solution:

Connection Refused

Cause: WHOIS server blocking your IP or down Solution:

Inconsistent Results

Cause: WHOIS data updated between lookups Solution:

Contributing

Contributions welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Add tests
  4. Submit a pull request

License

MIT License

Resources

Changelog

0.5.2

0.5.1

0.5.0