VPNDetectionVPNDetection

Ruby

The official Ruby client library for the VPNDetection API.

See on GitHub

Getting Started

gem install vpndetection

Or add it to your Gemfile:

gem 'vpndetection'

Requires Ruby 3.1 or newer.

Usage

No API key needed to start. The free tier answers ip and is_vpn, and allows 1000 requests per day per source address.

require 'vpndetection'

client = VPNDetection::Client.new

result = client.lookup('45.83.91.1')
result.is_vpn   # => true

With an API key

An API key raises your quota, and raises your features on a paid plan. Create one in the console, then pass it in:

client = VPNDetection::Client.new(api_key: ENV['VPNDETECTION_API_KEY'])

result = client.lookup('45.83.91.1')
result.is_vpn               # => true
result.vpn['provider']      # => "mullvad"
result.is_hosting           # => true
result.hosting['provider']  # => "M247"

Batch lookup

You can do batch lookups with a list, which parallelizes requests for you efficiently:

results = client.lookup_batch(['45.83.91.1', '8.8.8.8', '1.1.1.1'])

results.each do |ip, result|
  if result.is_a?(VPNDetection::Error)
    warn "#{ip}: #{result.message}"
    next
  end
  puts "#{ip}: #{result.is_vpn}"
end

Results are keyed by address, so duplicates in your list collapse into a single request and one address failing never loses the rest.

Concurrency and other variables are configurable per-call:

results = client.lookup_batch(many_ips, concurrency: 32, retries: 4)

Caching

Answers are cached by default, so repeat lookups of the same address are free:

client = VPNDetection::Client.new

result = client.lookup('45.83.91.1')
result.is_vpn    # => true, API request

result2 = client.lookup('45.83.91.1')
result2.is_vpn   # => true, no API request, result was cached

You can change the default cache variables (max size, TTL in seconds) on initialization, or even disable it:

client = VPNDetection::Client.new(cache_max_size: 50_000, cache_ttl: 6 * 60 * 60)
client_no_cache = VPNDetection::Client.new(cache: false)

Private and reserved addresses

Private, loopback, link-local, documentation and multicast addresses (and their IPv6 equivalents, including the 6to4 and Teredo ranges) can never be VPN or proxy infrastructure. The library answers them locally, so they cost no request and no quota:

result = client.lookup('192.168.1.1')
result.bogon?    # => true, this answer was computed rather than served
result.is_vpn    # => false

The check is available on the client, which is handy when your inputs are addresses anyway:

client.bogon?('10.0.0.1')   # => true
client.bogon?('8.8.8.8')    # => false

It is also on the module itself, if you want it without a client:

VPNDetection.bogon?('10.0.0.1')   # => true

Errors

Failures raise a VPNDetection::Error carrying a kind and a retryable? flag:

begin
  client.lookup('1.1.1.1')
rescue VPNDetection::Error => e
  warn "#{e.kind} #{e.retryable?}: #{e.message}"
end

kind is one of :bad_request, :unauthorized, :forbidden, :rate_limited, :quota_exceeded, :server_error or :network.

Note that :rate_limited and :quota_exceeded both arrive as HTTP 429 and are not the same thing. A rate limit is when the API faces extreme traffic bursts and so retrying later works; but a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, but not if your quota is exceeded.

Database downloads

If your key carries the db.download scope, the licensed databases are available through client.database. A license covers a database FAMILY and a download names one of its versions, so the id comes from versions:

family = client.database.list.first
id = family.versions.last.id

written = client.database.download(id, 'mmdb', './vpn_ip_extended_v1.mmdb')
url = client.database.download_url(id, 'mmdb')
bytes = client.database.download_bytes('cdn_ip_v1', 'csvgz')

download streams straight to disk, so nothing bigger than a chunk is ever held in memory whatever the database weighs, and it writes through a neighboring .part file so a transfer that dies half way leaves no truncated copy behind. download_url hands back the time-limited link and follows nothing, for when you want to run the transfer yourself. download_bytes holds the whole file in memory, and the catalog runs from cdn_ip_v1 at 10 KB to resproxy_ip_90d_v1 at 1.79 GB, so reach for download for anything you have not measured.

Absent is not false

Every field beyond ip and is_vpn is present when your plan includes it and nil when it does not. nil means "not in your plan"; false means "we checked, and no".

result.hosting?                 # false when absent, for when you only want the flag
result.included?(:is_hosting)   # whether your plan carries the field at all

On this page