Rust
The official Rust client library for the VPNDetection API.
Getting Started
cargo add vpndetectionRequires Rust 1.85 or newer. Everything is async and runs on tokio.
Usage
No API key needed to start. The free tier answers ip and is_vpn, and allows 1000 requests per day per source address.
use vpndetection::Client;
let client = Client::new()?;
let result = client.lookup("45.83.91.1").await?;
println!("{}", result.is_vpn); // trueWith 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:
let client = Client::builder()
.api_key(std::env::var("VPNDETECTION_API_KEY")?)
.build()?;
let result = client.lookup("45.83.91.1").await?;
println!("{}", result.is_vpn); // true
println!("{:?}", result.is_hosting); // Some(true)
if let Some(vpn) = result.vpn.as_deref() {
println!("{:?}", vpn.provider); // Some("mullvad")
}Every setting has a default, and Client::builder() is where you change one:
let client = Client::builder().api_key(key).concurrency(32).retries(4).build()?;Batch lookup
You can do batch lookups with a list, which parallelizes requests for you efficiently:
use vpndetection::BatchOptions;
let results = client
.lookup_batch(["45.83.91.1", "8.8.8.8", "1.1.1.1"], BatchOptions::new())
.await;
for (ip, result) in &results {
match result {
Ok(result) => println!("{ip}: {}", result.is_vpn),
Err(err) => eprintln!("{ip}: {err}"),
}
}Results are keyed by address and in the order you first listed each one, 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:
let results = client
.lookup_batch(many_ips, BatchOptions::new().concurrency(32).retries(4))
.await;Caching
Answers are cached by default, so repeat lookups of the same address are free:
let client = Client::new()?;
let result = client.lookup("45.83.91.1").await?;
println!("{}", result.is_vpn); // true, API request
let result2 = client.lookup("45.83.91.1").await?;
println!("{}", result2.is_vpn); // true, no API request, result was cachedYou can change the default cache variables (max size, TTL, etc) on initialization, or even disable it:
use std::time::Duration;
let client = Client::builder().cache(50_000, Duration::from_secs(6 * 60 * 60)).build()?;
let client_no_cache = Client::builder().no_cache().build()?;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:
let result = client.lookup("192.168.1.1").await?;
result.is_bogon; // true, this answer was computed rather than served
result.is_vpn; // falseBecause the answer is computed rather than served, it always carries every field, whatever plan you are on. Do not read your plan's shape off a private address.
The check is available on the client, which is handy when your inputs are addresses anyway:
client.is_bogon("10.0.0.1"); // true
client.is_bogon("8.8.8.8"); // falseIt is also callable on its own, if you want it without a client:
vpndetection::is_bogon("10.0.0.1"); // trueErrors
Failures return a vpndetection::Error carrying a kind() and a retryable() flag:
use vpndetection::ErrorKind;
match client.lookup("1.1.1.1").await {
Ok(result) => println!("{}", result.is_vpn),
Err(err) => println!("{} {}", err.kind(), err.retryable()),
}kind() is one of BadRequest, Unauthorized, Forbidden, RateLimited, QuotaExceeded, ServerError, Network or Io, the last of which is a database transfer that could not be written or that ended early.
Note that RateLimited and QuotaExceeded 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 licence covers a database FAMILY, so the id you download comes from one of its versions:
use vpndetection::Format;
let families = client.database().list().await?;
let url = client.database().download_url("vpn_ip_extended_v1", DatabaseFormat::Mmdb).await?;
let raw = client.database().download_bytes("cdn_ip_v1", DatabaseFormat::Csvgz).await?;
let written = client.database().download("vpn_ip_extended_v1", DatabaseFormat::Mmdb, "./vpn_ip.mmdb").await?;download_url hands back a time-limited link so you can run the transfer yourself. download streams to disk through a neighboring .part file, so nothing bigger than a chunk is ever held in memory and a transfer that dies half way leaves no truncated file. 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 use download for anything you have not measured.
TLS backends
rustls is the default, so the crate builds with no system libraries at all. If you would rather link the platform's TLS, or you already depend on reqwest with its own defaults and want one backend rather than two:
vpndetection = { version = "1", default-features = false, features = ["native-tls"] }Calling from synchronous code
There is no blocking facade, on purpose: reqwest::blocking builds its own runtime and panics when constructed inside one, so a facade would fail for exactly the callers most likely to reach for it. If you have no runtime, make the cost visible instead:
let runtime = tokio::runtime::Runtime::new()?;
let client = Client::new()?;
let result = runtime.block_on(client.lookup("45.83.91.1"))?;Absent is not false
Every field beyond ip and is_vpn is an Option, because your plan decides which of them the API sends. None means "not in your plan", which is not the same answer as Some(false), which means "checked, and no".
result.is_hosting.unwrap_or(false); // when you only want the flag
result.is_hosting.is_none(); // not in your plan