VPNDetectionVPNDetection

Zig

The official Zig client library for the VPNDetection API.

See on GitHub

Getting Started

zig fetch --save git+https://github.com/vpndetection-io/sdk-zig#v4.10.2

Then add the module to whatever you are building, in build.zig:

const vpndetection = b.dependency("vpndetection", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("vpndetection", vpndetection.module("vpndetection"));

Requires Zig 0.16.0 or 0.17.0. Zig is pre-1.0 and its standard library still changes shape between releases, so no other version is supported; CI builds and tests on both, and 0.16.0 is pinned in scripts/Dockerfile.

Usage

No API key needed to start. Without a key, a lookup answers ip and is_vpn, up to 1000 a day per source address.

const std = @import("std");
const vpndetection = @import("vpndetection");

pub fn main(init: std.process.Init) !void {
    var client = try vpndetection.Client.init(init.gpa, init.io, .{});
    defer client.deinit();

    const result = try client.lookup("45.83.91.1");
    defer result.deinit();

    std.debug.print("{}\n", .{result.value.is_vpn}); // true
}

The whole program is in example/lookup.zig; zig build example builds it.

init.io is the std.Io implementation your program already runs on. Outside main, build your own:

var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();

var client = try vpndetection.Client.init(gpa, threaded.io(), .{});
defer client.deinit();

Memory

Everything the client hands back is allocated with the allocator you gave init, and is yours to free. A Lookup and a Batch each carry a deinit, the catalog calls return a std.json.Parsed that owns its arena, and downloadUrl and downloadBytes return slices. The test suite runs under std.testing.allocator, so a leak fails the build.

With an API key

A free key adds Tor and relay detection, where the address is and the AS that announces it. Every paid plan answers every field, the same on each, and the plans differ only in how many lookups they include. Create a key in the console, then pass it in:

var client = try vpndetection.Client.init(gpa, io, .{ .api_key = key });
defer client.deinit();

const result = try client.lookup("45.83.91.1");
defer result.deinit();

std.debug.print("{}\n", .{result.value.is_vpn});                  // true
std.debug.print("{s}\n", .{result.value.location.?.country});     // the ISO 3166 country code
std.debug.print("{s}\n", .{result.value.asn.?.name});             // who runs the announcing network
std.debug.print("{s}\n", .{result.value.vpn.?.provider.?});       // nordvpn, on a paid plan
std.debug.print("{}\n", .{result.value.is_hosting orelse false}); // true, on a paid plan

The blanks around a key, a file's trailing newline among them, are dropped, and a key of blanks alone is no key at all. A key holding any other control character is refused: every call that would send it fails with BadRequest before anything leaves the client.

Your own address

const result = try client.myIp();
defer result.deinit();

std.debug.print("{s}\n", .{result.value.ip});   // the address we saw this call come from

Same answer lookup would give for that address, and the same cost against your allowance. It is deliberately not cached: which address you are is the whole question, and a machine that moves between networks would otherwise be told where it used to be.

Your plan and usage

const acct = try client.myEntitlement();
defer acct.deinit();

std.debug.print("{s}\n", .{acct.value.plan.key});        // max
std.debug.print("{d}\n", .{acct.value.usage.requests});  // 580

Usage counts against the anniversary of your subscription, not the calendar month and not the billing period, and it is the same number a lookup is gated on. hard_limit is null on an uncapped plan, which is not the same as zero.

Both are yours to free, like everything else the client hands back. myIpWith and myEntitlementWith take this call's own retry budget and a Diagnostics.

Batch lookup

Look up many addresses at once. Bogons and cached answers are handled locally, and everything else goes to the batch endpoint in chunks of up to 1000 addresses, in parallel:

var batch = try client.lookupBatch(&.{ "45.83.91.1", "8.8.8.8", "1.1.1.1" }, .{});
defer batch.deinit();

for (batch.keys(), batch.values()) |ip, entry| {
    switch (entry) {
        .ok => |answer| std.debug.print("{s}: {}\n", .{ ip, answer.value.is_vpn }),
        .failed => |failure| std.debug.print("{s}: {s}\n", .{ ip, failure.diagnostics.message() }),
    }
}

Results are keyed by address, in the order you first listed each one, so duplicates in your list collapse into a single entry and one address failing never loses the rest: it carries its error as its value, with the status the API would have given that address on its own.

How many chunks are in flight at once, and how many times a failed chunk is retried, are configurable per call:

var batch = try client.lookupBatch(many_ips, .{ .concurrency = 4, .retries = 4 });
defer batch.deinit();

A batch is only as concurrent as the std.Io you gave the client: std.Io.Threaded runs the requests on its thread pool, and an implementation that cannot start a second task answers them one at a time.

Caching

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

const first = try client.lookup("45.83.91.1");  // API request
defer first.deinit();

const second = try client.lookup("45.83.91.1"); // no API request, served from the cache
defer second.deinit();

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

var client = try vpndetection.Client.init(gpa, io, .{
    .cache = .{ .max_entries = 50_000, .ttl = .fromSeconds(6 * 60 * 60) },
});

var uncached = try vpndetection.Client.init(gpa, io, .{ .cache = null });

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:

const result = try client.lookup("192.168.1.1");
defer result.deinit();

result.is_bogon;      // true, this answer was computed rather than served
result.value.is_vpn;  // false

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

client.isBogon("10.0.0.1"); // true
client.isBogon("8.8.8.8");  // false

It is also callable on its own, if you want it without a client:

vpndetection.isBogon("10.0.0.1"); // true

Errors

Failures are values in vpndetection.Error, and the detail behind one arrives in a Diagnostics you pass in:

var diagnostics: vpndetection.Diagnostics = .{};

const result = client.lookupWith("1.1.1.1", .{ .diagnostics = &diagnostics }) catch |err| {
    std.debug.print("{s} retryable={} status={?} {s}\n", .{
        vpndetection.kindName(err),
        vpndetection.isRetryable(err),
        diagnostics.status,
        diagnostics.message(),
    });
    return err;
};
defer result.deinit();

The error set is BadRequest, Unauthorized, Forbidden, RateLimited, QuotaExceeded, ServerError and Network, plus OutOfMemory from the allocator.

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.

Timeouts

Each attempt of a call gets 30 seconds by default, from connecting to the last byte of the answer. Set your own with timeout, on the client or on a single call:

var client = try vpndetection.Client.init(gpa, threaded.io(), .{ .timeout = .fromSeconds(10) });
defer client.deinit();

const result = try client.lookupWith("1.1.1.1", .{ .timeout = .fromMilliseconds(500) });
defer result.deinit();

A call's own value replaces the client's, longer or shorter, and BatchOptions, OauthOptions and DeviceAuthorizationOptions take one too. A call that runs out of time fails with error.Network, which is retried like any other network failure, and each retry gets the whole timeout again. A download is only held to it until object storage starts answering, so a large dataset is never cut off part way, and pollDeviceToken holds each request to it rather than the whole wait.

The timeout needs a std.Io that can run a second task, such as std.Io.Threaded. On one that can't, a call runs without it.

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 you download one of its versions:

const databases = try client.database().list(.{});
defer databases.deinit();
const id = databases.value[0].versions[0].id; // e.g. vpn_ip_extended_v1

// A time-limited link, so something else can do the transfer.
const url = try client.database().downloadUrl(id, .mmdb, .{});
defer gpa.free(url);

// The bytes, in memory.
const bytes = try client.database().downloadBytes(id, .mmdb, .{});
defer gpa.free(bytes);

// Straight to a file, which is the one to reach for by default.
const written = try client.database().download(id, .mmdb, "vpn_ip.mmdb", .{});

download holds nothing but a single chunk in memory whatever the dataset weighs. It writes to a neighbouring .part file and renames it on completion, and a transfer that stops short of the length the origin declared is an error rather than a short file, so a path that exists is a whole dataset and nothing partial survives a failure.

downloadBytes holds the entire file in memory. The catalog spans five orders of magnitude, from cdn_ip_v1 at ~10 KB to resproxy_ip_90d_v1 at 1.79 GB, and a 1.79 GB dataset is 1.79 GB of resident memory here, so reach for it at the small end. client.database().metadata(id, .{}) publishes the size per format without transferring anything, which is how you find out which end you are at.

downloadUrl hands back the link rather than the bytes, so you choose how to move the file; the link authorizes the START of a transfer, so one already running is not interrupted when it lapses. The client never follows that redirect for you. download and downloadBytes do follow it, and that second request carries no API key: the link authorizes itself, and object storage has no business holding your credential.

Sign in with OAuth (device flow)

A program running on the person's own machine can let them sign in with a browser and pick one of their API keys, instead of asking them to paste it:

var client = try vpndetection.Client.init(gpa, threaded.io(), .{});
defer client.deinit();
const device = try client.oauth().deviceAuthorization("your-client-id", .{ .scope = "account.read apikeys.read apikeys.reveal" });
defer device.deinit();
std.debug.print("Open {s} and enter {s}\n", .{ device.value.verification_uri, device.value.user_code });

const token = try client.oauth().pollDeviceToken("your-client-id", device.value, .{});
defer token.deinit();
const apikey = token.value.apikey orelse return error.NoApiKey; // none was picked, or it cannot be shown again
var keyed = try vpndetection.Client.init(gpa, threaded.io(), .{ .api_key = apikey });
defer keyed.deinit();

A denied sign-in fails with error.OauthAccessDenied and a code that ran out with error.OauthExpiredToken. Client IDs are issued on request from support@vpndetection.io, and client.oauth().revoke("your-client-id", refresh_token, .{}) signs the machine out again.

Sign in with OAuth (authorization code)

An app that can take a browser redirect signs the person in there instead, with a PKCE pair made for that one sign-in:

var client = try vpndetection.Client.init(gpa, threaded.io(), .{});
defer client.deinit();
const redirect_uri = "http://127.0.0.1:8765/callback";
const pkce = client.oauth().createPkce();

const url = try client.oauth().authorizationUrl("your-client-id", redirect_uri, &pkce.challenge, .{ .scope = "apikeys.use", .state = "your-state" });
defer gpa.free(url);
// Open url in the browser. Its redirect to redirect_uri carries code and state.
const token = try client.oauth().exchangeAuthorizationCode("your-client-id", code, &pkce.verifier, redirect_uri, .{});
defer token.deinit();

Check that state came back as you sent it before you exchange code, which works once. The client ID can also be the https URL of a client metadata document your app serves, and such an app is never handed a key, so token.value.apikey stays null.

Register a client

An app with no client ID, and no client metadata document to use as one, registers itself, then signs in with the client_id it gets back as the client ID for authorizationUrl and exchangeAuthorizationCode:

var client = try vpndetection.Client.init(gpa, threaded.io(), .{});
defer client.deinit();
const registration = try client.oauth().register(&.{"http://127.0.0.1:8765/callback"}, .{ .client_name = "Your App" });
defer registration.deinit();

Each redirect URI must be https on a public host name, or http on a loopback address, which then matches any port. Nothing is kept until a person approves the client, so an app with no client ID saved can simply register again.

Absent is not false

Without a key only ip and is_vpn come back, and a free key adds Tor, relay, location and asn. Every field beyond ip and is_vpn is optional, because your plan decides which of them the API sends. null means "not in your plan", not "checked, and no".

const hosting = result.value.is_hosting orelse false; // when you only want the flag
if (result.value.is_hosting == null) { ... }          // not in your plan

On this page