VPNDetectionVPNDetection

Swift

The official Swift client library for the VPNDetection API.

See on GitHub

Getting Started

Add the package to your Package.swift:

dependencies: [
    .package(url: "https://github.com/vpndetection-io/sdk-swift.git", from: "3.0.1"),
]

and the library to your target. The repository ends in sdk-swift, which is the package name SwiftPM derives, but the library it exposes is VPNDetection:

.target(
    name: "YourTarget",
    dependencies: [.product(name: "VPNDetection", package: "sdk-swift")],
)

Requires Swift 6.1 or newer, and macOS 13, iOS 16, tvOS 16, watchOS 9 or visionOS 1 on Apple platforms. Linux is supported on any distribution the Swift toolchain runs on.

Usage

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

import VPNDetection

let client = VPNDetectionClient()

let result = try await client.lookup("45.83.91.1")
print(result.isVpn)   // 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:

let client = VPNDetectionClient(apiKey: ProcessInfo.processInfo.environment["VPNDETECTION_API_KEY"]!)

let result = try await client.lookup("45.83.91.1")
print(result.isVpn)             // true
print(result.vpn?.provider)     // Optional("mullvad")
print(result.isHosting)         // Optional(true)
print(result.hosting?.provider)

Every setting has a default, and VPNDetectionClient.Options is where you change one:

let client = VPNDetectionClient(options: .init(apiKey: key, concurrency: 32, retries: 4))

Batch lookup

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

let results = try await client.lookupBatch(["45.83.91.1", "8.8.8.8", "1.1.1.1"])

for (ip, outcome) in results {
    switch outcome {
    case .success(let result):
        print("\(ip): \(result.isVpn)")
    case .failure(let error):
        print("\(ip): \(error)")
    }
}

Results are keyed by address, so duplicates in your list collapse into a single request and one address failing never loses the rest. results["8.8.8.8"] gets one back on its own, and results.keys is the order you passed in.

Concurrency and other variables are configurable per-call:

let results = try await client.lookupBatch(manyIps, options: .init(concurrency: 32, retries: 4))

Caching

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

let client = VPNDetectionClient()

let result = try await client.lookup("45.83.91.1")
print(result.isVpn)    // true, API request

let result2 = try await client.lookup("45.83.91.1")
print(result2.isVpn)   // true, no API request, result was cached

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

let client = VPNDetectionClient(options: .init(cache: .init(maxEntries: 50_000, ttl: .seconds(6 * 3600))))
let clientNoCache = VPNDetectionClient(options: .init(cache: nil))

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 = try await client.lookup("192.168.1.1")
result.isBogon   // true, this answer was computed rather than served
result.isVpn     // 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 a free function, if you want it without a client:

import VPNDetection

isBogon("10.0.0.1")   // true

Errors

Failures throw a VPNDetectionError carrying a kind and an isRetryable flag:

do {
    _ = try await client.lookup("1.1.1.1")
} catch let error as VPNDetectionError {
    print(error.kind, error.isRetryable)
}

kind is one of badRequest, unauthorized, forbidden, rateLimited, quotaExceeded, serverError or network.

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. download fetches one to a file, streaming it straight to disk so that nothing bigger than a chunk is ever held in memory:

let databases = try await client.database.list()

let written = try await client.database.download(
    "vpn_ip_extended_v1", format: .mmdb,
    to: URL(fileURLWithPath: "vpn_ip_extended_v1.mmdb"),
)
print("\(written) bytes")

Or take the time-limited link and run the transfer yourself, or take a small database as bytes:

let url = try await client.database.downloadURL(id: "vpn_ip_extended_v1", format: .mmdb)
let bytes = try await client.database.downloadBytes("cdn_ip_v1", format: .csvgz)

downloadBytes 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.

Absent is not false

Only ip and isVpn come back on every plan. The rest are Optional, where nil means "not in your plan" rather than "checked, and no".

result.isHosting ?? false   // when you only want the flag
result.isHosting == nil     // when "not in my plan" has to be told apart

Supplying your own transport

By default the library talks to the API over AsyncHTTPClient, configured to refuse redirects. Anything conforming to ClientTransport can take its place. To use URLSession on an Apple platform, add swift-openapi-urlsession to your own package and hand its transport in:

import OpenAPIURLSession

let client = VPNDetectionClient(options: .init(transport: URLSessionTransport()))

One thing to know if you do: the download endpoint answers 302, and the library follows that redirect itself as a second request rather than letting the transport do it, so a transport that follows redirects would read a whole database into memory before the library ever saw the link. Configure yours not to. The library refuses such a response rather than reading it, but the transfer has already started by then.

On this page