Proxy/VPN/Tor Filter Script (Python)

This Python script reads a text file of IP addresses (one per line), checks each one against the free ip.xakki.pro API's proxy/VPN/Tor and hosting signals, and splits the list into clean.txt (no signal) and flagged.txt (at least one signal, with the reason written next to each address) — treating a rate-limited, unverifiable IP as flagged rather than silently letting it through as clean.

How to run this script

placeholder

  1. Download proxy-filter.py below.
  2. Prepare a text file with one IP address per line.
  3. Run: python3 proxy-filter.py ips.txt
  4. Read clean.txt and flagged.txt — flagged.txt has a tab-separated reason after each IP.

Full source code

proxy-filter.py Download proxy-filter.py
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""proxy-filter.py -- split a list of IP addresses into clean.txt/flagged.txt
by proxy/VPN/Tor/hosting signal, via the ip.xakki.pro API.

Reads a text file with one IP address per line, calls
GET /api/v1/ip?ip=<addr>&include=security for each one, and writes:
  - clean.txt   -- IPs with no proxy/VPN/Tor/hosting signal.
  - flagged.txt -- IPs with at least one signal, one per line, with the
                    reason(s) appended after a tab.

An IP is flagged when ANY of these hold:
  - privacy.isProxy   -- a known anonymizing-proxy network (IPFire libloc).
  - privacy.isAnonymous -- isProxy OR isVpn OR isTor (the API's own rollup).
  - network.classification.type is "hosting" or "datacenter" -- not itself
    proof of a proxy, but a strong correlate (real residential ISPs almost
    never show up here) worth surfacing as its own reason.

An IP whose "security" group comes back rate-limited (withheld -- see the
rate-limit note in bulk-enrich.py's docstring) is CONSERVATIVELY flagged
with reason "unverified: rate-limited" rather than assumed clean -- silently
letting an unverified IP through to clean.txt would defeat the point of this
script. Re-run later (a repeat lookup of the same IP the same UTC day is
free once the group's budget resets) to get a real verdict.

A non-routable input (private/loopback/CGNAT/bogon) gets NO privacy signal
at all -- the API returns "privacy": null for those (there is no meaningful
proxy/VPN classification for an address that was never publicly routable),
so this script treats it as clean by omission. That is intentional, not a
gap: a private-range IP has no business appearing in a real proxy-filter
input in the first place.

Zero dependencies -- stdlib only. Python 3.8+.

Usage:
    python3 proxy-filter.py ips.txt
    python3 proxy-filter.py ips.txt --delay 1.0 --base-url http://localhost:11120
"""

import argparse
import ipaddress
import json
import sys
import time
import urllib.error
import urllib.request

DEFAULT_BASE_URL = "https://ip.xakki.pro"
USER_AGENT = "myip-proxy-filter-recipe/1.0 (+https://ip.xakki.pro/tools/scripts/proxy-filter.html)"
HOSTING_TYPES = {"hosting", "datacenter"}


def fetch_security(base_url, ip_value, max_retries=5, base_backoff=0.5):
    url = "{}/api/v1/ip?ip={}&include=security".format(base_url, ip_value)
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
    attempt = 0
    while True:
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            if not (e.code == 429 or e.code >= 500) or attempt >= max_retries:
                raise
            wait = float(e.headers.get("Retry-After", 0)) or (base_backoff * (2 ** attempt))
        except (urllib.error.URLError, TimeoutError, ConnectionError):
            if attempt >= max_retries:
                raise
            wait = base_backoff * (2 ** attempt)
        attempt += 1
        time.sleep(wait)


def classify(base_url, ip_value):
    """Returns (is_flagged, reasons_list) for one IP. Never raises -- a
    transport failure or an invalid input is itself a flagged reason."""
    try:
        ipaddress.ip_address(ip_value)
    except ValueError:
        return True, ["invalid IP, could not check"]

    try:
        data = fetch_security(base_url, ip_value)
    except Exception as e:  # noqa: BLE001
        return True, ["unverified: request failed ({})".format(e)]

    try:
        if ipaddress.ip_address(data.get("ip", "")) != ipaddress.ip_address(ip_value):
            return True, ["unverified: unexpected response (ip mismatch)"]
    except ValueError:
        return True, ["unverified: unexpected response"]

    if "security" in (data.get("limited") or []):
        return True, ["unverified: rate-limited"]

    reasons = []
    privacy = data.get("privacy") or {}
    if privacy.get("isProxy"):
        reasons.append("privacy.isProxy")
    if privacy.get("isAnonymous"):
        reasons.append("privacy.isAnonymous")
    net_type = ((data.get("network") or {}).get("classification") or {}).get("type")
    if net_type in HOSTING_TYPES:
        reasons.append("network type = {}".format(net_type))

    return (len(reasons) > 0), reasons


def main():
    parser = argparse.ArgumentParser(description="Split an IP list into clean/flagged by proxy signal.")
    parser.add_argument("input_file", help="Path to a text file, one IP per line.")
    parser.add_argument("--clean-out", default="clean.txt", help="Output file for clean IPs (default: clean.txt).")
    parser.add_argument("--flagged-out", default="flagged.txt", help="Output file for flagged IPs (default: flagged.txt).")
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API base URL (default: production).")
    parser.add_argument("--delay", type=float, default=0.5, help="Seconds between lookups (default: 0.5).")
    args = parser.parse_args()

    with open(args.input_file, encoding="utf-8") as f:
        ip_lines = [line.strip() for line in f if line.strip()]

    clean, flagged = [], []
    for i, ip_value in enumerate(ip_lines):
        is_flagged, reasons = classify(args.base_url, ip_value)
        if is_flagged:
            flagged.append((ip_value, reasons))
        else:
            clean.append(ip_value)
        print("[{}/{}] {} -> {}".format(i + 1, len(ip_lines), ip_value,
                                         "FLAGGED: " + ", ".join(reasons) if is_flagged else "clean"),
              file=sys.stderr)
        if i < len(ip_lines) - 1:
            time.sleep(args.delay)

    with open(args.clean_out, "w", encoding="utf-8") as f:
        f.write("\n".join(clean) + ("\n" if clean else ""))
    with open(args.flagged_out, "w", encoding="utf-8") as f:
        for ip_value, reasons in flagged:
            f.write("{}\t{}\n".format(ip_value, "; ".join(reasons)))

    print("done: {} clean -> {}, {} flagged -> {}".format(
        len(clean), args.clean_out, len(flagged), args.flagged_out), file=sys.stderr)


if __name__ == "__main__":
    main()

This script is MIT-licensed (SPDX-License-Identifier: MIT header at the top of the file) — copy, modify and reuse it freely in your own project, no attribution required.

Proxy/VPN/Tor Filter Script (Python)

An IP is flagged when ANY of three independent signals fires: privacy.isProxy (a known anonymizing-proxy network), privacy.isAnonymous (the API's own isProxy-OR-isVpn-OR-isTor rollup), or a network.classification.type of "hosting"/"datacenter" — not itself proof of a proxy, but a strong correlate worth surfacing as its own named reason rather than folding silently into one boolean. Every flagged line in flagged.txt says WHICH of these fired, not just that something did.

A non-routable input (private/loopback/CGNAT/bogon) gets "privacy": null from the API — there is no meaningful proxy classification for an address that was never publicly routable — and this script treats that as clean by omission rather than as an error, since a private-range address has no business appearing in a real proxy-filter input to begin with.

"Unverified" outcomes (a rate-limited security group, a transport failure, or an invalid input line) are all routed to flagged.txt with an explicit "unverified: ..." reason instead of clean.txt — this is a deliberately conservative, fail-closed design: for a security-adjacent filter, treating "couldn't check" the same as "checked and clean" would silently defeat the whole point of running it. Re-running later picks up a real verdict once the rate limit resets (a repeat lookup of the same IP later the same UTC day is free).

A note on rate limits

GET /api/v1/ip never returns a hard 429 from the app itself — its enrichment groups (geo, network, security) are metered per unique target IP per UTC day, plus a tighter per-minute budget, and going over either soft-withholds the group instead: HTTP 200, "status":"partial", the group's name in "limited", and RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset headers on the response. This script reads those signals rather than guessing, and records a withheld result explicitly (never as a blank/null, which would look identical to "the API has no data for this IP"). A real 429/503 can still happen at the edge — nginx's own flood guard, independent of the metering above — if you call the endpoint too fast from one machine; the script backs off exponentially on any 429/5xx and honors a Retry-After header when the server sends one. Anonymous callers get a modest daily/per-minute budget per group (see the RateLimit-Limit header on any response, or /api/v1/tiers); register a free API token for a much larger one if you plan to run this against more than a handful of IPs.

Glossary

privacy.isProxy
The API's flag for a known anonymizing-proxy network address (IPFire libloc data) — one of the three independent signals this script checks.
privacy.isAnonymous
The API's own rollup: true if isProxy OR isVpn OR isTor is true — a broader "anonymized somehow" signal than isProxy alone.
hosting/datacenter classification
network.classification.type when the address belongs to a hosting/cloud provider rather than a residential ISP — a correlate for proxy usage, not proof of it on its own.
Fail-closed
A design where an uncertain or failed check defaults to the MORE restrictive outcome (here: flagged, not clean) rather than the more permissive one.
Bogon
An address from a range that should never appear on the public internet (unassigned/reserved space) — one of several non-routable categories this script's private-IP handling covers alongside it.
Tab-separated reason
flagged.txt's own format: the IP address, a tab character, then a semicolon-joined list of every reason that fired for it.

API equivalent

This script itself is the API example — there is no separate curl/response block to show, because the code above already IS the request. See the full response schema and every parameter in the API docs:

Read the full API docs

Frequently asked questions

What counts as "flagged" — is a false positive possible?
Yes, especially from the hosting/datacenter signal alone — a legitimate server, a company VPN endpoint, or a CDN edge node can share the same classification as an anonymizing proxy. Treat flagged.txt as "needs a closer look", not an automatic ban list; the reason column tells you exactly which signal to weigh.
Why two output files instead of one CSV with a boolean column?
clean.txt and flagged.txt are meant to be piped directly into something else (an allow-list, a block-list) without any further parsing — a plain one-IP-per-line file is the simplest possible interchange format for that.
Does this detect residential proxies?
No — the API's privacy block explicitly reports isResidentialProxy as always false (no free data source for it today, documented as a known gap), so this script inherits that same limitation honestly rather than pretending otherwise.
Can I run this against my own server's connection log IPs?
Yes — that is exactly the intended use: paste unique visitor IPs (see the log-aggregate.py recipe for extracting them from a raw access log first) and get a clean/flagged split back.
Is Tor itself always flagged?
Yes — privacy.isTor feeds directly into privacy.isAnonymous, which is one of this script's three flagging signals, so a Tor exit node is always flagged (reason: privacy.isAnonymous, often alongside privacy.isProxy).

Like this tool? Our API gives free accounts 1,000 requests/day.See the API docs →