This package provides a simple way to download the Tor exit node list from the Onionoo API and check whether an IP address belongs to a Tor exit node. See the examples section for usage.
Main features:
- Downloader — retrieves the exit node list from the Onionoo API, in memory, with no filesystem involved.
- Filesystem writer — saves a downloaded (or hand-built) list to a local JSON file.
- Filesystem reader — loads a previously saved list back from that file.
- Checker — answers whether an IP address is a known exit node, with no network or filesystem access.
Code quality features:
- Modular by design — each piece above works standalone; use any of them without the others.
- IPv4 and IPv6 support — addresses are compared in canonical form, so notation differences don't cause false negatives.
- Typed exceptions — every failure mode has a dedicated exception, all catchable through one common interface.
- Swappable HTTP client — ships with a Guzzle implementation, but the transport is an interface you can replace.
- PHP 8.2 or newer
- Composer
composer require binarylogic/tor-exit-nodesThe file's modification time is the only bookkeeping involved — no cache driver, no scheduled job, no extra metadata to keep in sync. Saving goes through an atomic write, so a request that reads the file while another one refreshes it still sees a complete list.
use Binarylogic\TorExitNodes\Downloader\ExitNodeDownloader;
use Binarylogic\TorExitNodes\Http\GuzzleHttpClient;
use Binarylogic\TorExitNodes\Storage\ExitNodeFileReader;
use Binarylogic\TorExitNodes\Storage\ExitNodeFileWriter;
$path = __DIR__.'/storage/tor-exit-nodes.json';
$maxAgeInSeconds = 3600;
$isStale = ! is_file($path) || filemtime($path) < time() - $maxAgeInSeconds;
if ($isStale) {
$exitNodes = (new ExitNodeDownloader(new GuzzleHttpClient()))->downloadExitNodes();
(new ExitNodeFileWriter($path))->saveExitNodes($exitNodes);
} else {
$exitNodes = (new ExitNodeFileReader($path))->loadExitNodes();
}Catching both exceptions covers an unreachable Onionoo and a response that arrived but made no sense, and the saved file is only ever replaced once a payload has parsed cleanly. Note that the fallback needs a file to fall back to: on a first run with nothing saved yet, the reader throws LoadFailedException.
use Binarylogic\TorExitNodes\Downloader\ExitNodeDownloader;
use Binarylogic\TorExitNodes\Exception\DownloadFailedException;
use Binarylogic\TorExitNodes\Exception\MalformedExitNodeListException;
use Binarylogic\TorExitNodes\Http\GuzzleHttpClient;
use Binarylogic\TorExitNodes\Storage\ExitNodeFileReader;
use Binarylogic\TorExitNodes\Storage\ExitNodeFileWriter;
$path = __DIR__.'/storage/tor-exit-nodes.json';
try {
$exitNodes = (new ExitNodeDownloader(new GuzzleHttpClient()))->downloadExitNodes();
(new ExitNodeFileWriter($path))->saveExitNodes($exitNodes);
} catch (DownloadFailedException|MalformedExitNodeListException) {
// Onionoo is unreachable or returned something unexpected; keep using the last saved list.
$exitNodes = (new ExitNodeFileReader($path))->loadExitNodes();
}Drop this in a front controller or middleware. Behind a proxy or load balancer, REMOTE_ADDR is the proxy's address rather than the visitor's, so read the client address from your framework's trusted-proxy handling instead. Each request pays one file read here; in a long-lived process, load the list once and reuse the checker.
use Binarylogic\TorExitNodes\Checker\ExitNodeChecker;
use Binarylogic\TorExitNodes\Storage\ExitNodeFileReader;
$reader = new ExitNodeFileReader(__DIR__.'/storage/tor-exit-nodes.json');
$checker = new ExitNodeChecker($reader->loadExitNodes());
if ($checker->isExitNode($_SERVER['REMOTE_ADDR'])) {
http_response_code(403);
exit;
}Useful for sifting Tor traffic out of log entries, an export, or a report. Lookups hit an in-memory hash map, so the size of the list barely affects the cost of a pass. Unparseable entries are treated as non-Tor here; rethrow instead if malformed input is something you would rather hear about.
use Binarylogic\TorExitNodes\Checker\ExitNodeChecker;
use Binarylogic\TorExitNodes\Exception\InvalidIpAddressException;
$checker = new ExitNodeChecker($exitNodes);
$ipAddressesFromLogFile = ['185.220.101.1', '8.8.8.8', '2001:db8::1', 'not-an-ip'];
$torIpAddresses = array_filter($ipAddressesFromLogFile, function (string $ipAddress) use ($checker): bool {
try {
return $checker->isExitNode($ipAddress);
} catch (InvalidIpAddressException) {
return false;
}
});| Class | Responsibility |
|---|---|
Binarylogic\TorExitNodes\Downloader\ExitNodeDownloader |
Retrieves the exit node list from Onionoo |
Binarylogic\TorExitNodes\Http\HttpClient |
HTTP abstraction the downloader depends on |
Binarylogic\TorExitNodes\Http\GuzzleHttpClient |
Guzzle implementation of HttpClient |
Binarylogic\TorExitNodes\Parser\OnionooExitNodeParser |
Turns an Onionoo JSON response into an ExitNodeList |
Binarylogic\TorExitNodes\Storage\ExitNodeFileWriter |
Saves an ExitNodeList to a JSON file |
Binarylogic\TorExitNodes\Storage\ExitNodeFileReader |
Loads an ExitNodeList from a JSON file |
Binarylogic\TorExitNodes\Storage\ExitNodeListJsonSerializer |
Encodes and decodes the stored JSON format |
Binarylogic\TorExitNodes\Checker\ExitNodeChecker |
Answers whether an IP address is an exit node |
Binarylogic\TorExitNodes\ExitNode |
Immutable single exit node address |
Binarylogic\TorExitNodes\ExitNodeList |
Immutable collection of ExitNode values |
use Binarylogic\TorExitNodes\Downloader\ExitNodeDownloader;
use Binarylogic\TorExitNodes\Http\GuzzleHttpClient;
$downloader = new ExitNodeDownloader(new GuzzleHttpClient());
$exitNodes = $downloader->downloadExitNodes();
echo count($exitNodes);
echo implode(PHP_EOL, $exitNodes->allIpAddresses());Nothing is written to disk. The result is an in-memory ExitNodeList.
To keep the raw Onionoo response instead of a parsed list:
$json = $downloader->fetchRawList();The endpoint, HTTP client and parser are all injectable:
$downloader = new ExitNodeDownloader(
httpClient: new GuzzleHttpClient(timeoutSeconds: 10.0, userAgent: 'my-app/1.0'),
endpoint: 'https://onionoo.torproject.org/details?type=relay&running=true&flag=Exit&fields=or_addresses,exit_addresses',
);To use an HTTP client other than Guzzle, implement HttpClient:
use Binarylogic\TorExitNodes\Http\HttpClient;
final class MyHttpClient implements HttpClient
{
public function fetchBody(string $url): string
{
// Return the response body, or throw a DownloadFailedException.
}
}
$downloader = new ExitNodeDownloader(new MyHttpClient());use Binarylogic\TorExitNodes\Storage\ExitNodeFileWriter;
$writer = new ExitNodeFileWriter(__DIR__.'/storage/tor-exit-nodes.json');
$writer->saveExitNodes($exitNodes);The writer accepts any ExitNodeList, whether it came from a download, a file, or was built by hand.
use Binarylogic\TorExitNodes\Storage\ExitNodeFileReader;
$reader = new ExitNodeFileReader(__DIR__.'/storage/tor-exit-nodes.json');
$exitNodes = $reader->loadExitNodes();use Binarylogic\TorExitNodes\Checker\ExitNodeChecker;
$checker = new ExitNodeChecker($exitNodes);
if ($checker->isExitNode('185.220.101.1')) {
// The request came through the Tor network.
}ExitNodeChecker never touches the network or the filesystem. It works with a list from any origin:
use Binarylogic\TorExitNodes\ExitNodeList;
$checker = new ExitNodeChecker(ExitNodeList::fromIpAddresses(['185.220.101.1', '2001:db8::1']));IPv4 and IPv6 addresses are both supported. Addresses are compared in canonical form, so 2001:DB8::1 and 2001:db8:0:0:0:0:0:1 are treated as the same node.
use Binarylogic\TorExitNodes\Parser\OnionooExitNodeParser;
$exitNodes = (new OnionooExitNodeParser())->parseExitNodeList($json);{"exit_nodes":["185.220.101.1","2001:db8::1"]}Every exception thrown by the package implements Binarylogic\TorExitNodes\Exception\TorExitNodeException, so all of them can be caught at once.
| Exception | Thrown when |
|---|---|
DownloadFailedException |
The list could not be retrieved over HTTP |
MalformedExitNodeListException |
A JSON payload is not a valid exit node list |
SaveFailedException |
The list could not be written to disk |
LoadFailedException |
The list could not be read from disk |
InvalidIpAddressException |
An IP address given to the package is not valid |
use Binarylogic\TorExitNodes\Exception\TorExitNodeException;
try {
$exitNodes = $downloader->downloadExitNodes();
} catch (TorExitNodeException $exception) {
// Every failure mode of the package lands here.
}MIT