<?php
/**
* Prefix WhoIs client library for PHP
*
* A small, dependency-free client for the Prefix WhoIs service (whois.pwhois.org):
* one address at a time, or up to 500 per round trip in bulk mode. Answers come back
* as arrays keyed by the field names the server uses, lower-cased ("origin-as",
* "as-org-name", "prefix", "country-code", ...).
*
* require 'pwhois.php';
*
* $one = pwhois_lookup('4.2.2.1'); // ?array
* $many = pwhois_lookup_bulk(['4.2.2.1', '2001:4860:4860::8888', '10.0.0.1']);
* // => ['4.2.2.1' => [...], '2001:4860:4860::8888' => [...], '10.0.0.1' => null]
*
* pwhois_cache_enable(); // optional: 24-hour LRU cache (SQLite, built into PHP)
* $q = pwhois_quota(); // your address's daily limit, used, remaining
*
* Every query costs one unit of your daily allowance, so the library never asks twice:
* duplicates in a batch are collapsed, and with the cache enabled an address already
* answered today is served from disk. Requires PHP 7.4 or later (8.x recommended) with
* stream sockets; nothing else. Public service limits apply: 5,000 queries per source
* address per day by default. Higher limits are customarily granted free upon reasonable
* request; see https://pwhois.org/contact.who?contact=request
*
* Copyright (c) 2005-2026 VOSTROM Holdings, Inc. Released under the same open source
* license as the Prefix WhoIs software; see https://pwhois.org/license.who
*/
declare(strict_types=1);
const PWHOIS_SERVER = 'whois.pwhois.org';
const PWHOIS_PORT = 43;
const PWHOIS_APP = 'pwhois.php/2.0';
const PWHOIS_USER_AGENT = 'pwhois.php/2.0 (VOSTROM; +https://pwhois.org/php.who)';
const PWHOIS_BULK_MAX = 500; // addresses per round trip; the server accepts more, this keeps answers prompt
const PWHOIS_CONNECT_SECS = 10; // connect timeout
const PWHOIS_READ_SECS = 20; // overall read deadline per round trip
const PWHOIS_MAX_BYTES = 4 << 20; // 4 MB answer cap per round trip
/**
* Look up one IPv4 or IPv6 address.
*
* Returns the record as [field => value], or null when the address is invalid, the
* server could not be reached, or it had nothing to say (unrouted, unregistered space).
* Values the server marks NULL are returned as PHP null. $options: 'app' (string shown
* in the server's statistics), 'type' ('pwhois' default, or 'all' for the registry
* record too), 'server', 'port'.
*/
function pwhois_lookup(string $address, array $options = []): ?array
{
$ip = pwhois_normalize_ip($address);
if ($ip === null) return null;
$type = ($options['type'] ?? 'pwhois') === 'all' ? 'all' : 'pwhois';
$hit = pwhois_cache_get($ip, $type);
if ($hit !== false) return $hit;
$raw = pwhois_transact(pwhois_request_header($options) . ($type === 'all' ? 'type=all ' : '') . $ip . "\n", $options);
if ($raw === null) return null;
pwhois_usage_count(1);
$records = pwhois_parse($raw);
$rec = ($records && pwhois_has_content($records[0])) ? $records[0] : null;
pwhois_cache_put($ip, $type, $rec);
return $rec;
}
/** True when the server knows the address: it is routed or registered. Unrouted, unregistered space comes back as all NULL (plus zero coordinates). */
function pwhois_has_content(array $rec): bool
{
foreach (['origin-as', 'prefix', 'org-name', 'net-name', 'as-org-name', 'org-id', 'net-range'] as $k) if (($rec[$k] ?? null) !== null) return true;
return false;
}
/**
* Look up many addresses in bulk mode. Duplicates are collapsed, invalid entries are
* reported as null, and the result is keyed by the address exactly as you passed it.
*
* Returns [address => record|null]. Returns null only when no round trip could be
* completed at all (connection failure); a partial failure leaves the missing
* addresses null and sets $options['errors'] (by reference) with a message per batch.
*/
function pwhois_lookup_bulk(array $addresses, ?array &$options = null): ?array
{
$options = $options ?? [];
$result = []; $byIp = []; $order = [];
foreach ($addresses as $a) {
if (!is_string($a) && !is_int($a)) continue;
$key = (string) $a; $ip = pwhois_normalize_ip($key);
$result[$key] = null;
if ($ip === null) continue;
if (!isset($byIp[$ip])) { $byIp[$ip] = []; $order[] = $ip; }
$byIp[$ip][] = $key;
}
if (!$order) return $result;
// Serve what the cache already knows; only the rest goes to the server.
$pending = [];
foreach ($order as $ip) {
$hit = pwhois_cache_get($ip, 'pwhois');
if ($hit !== false) { foreach ($byIp[$ip] as $key) $result[$key] = $hit; } else $pending[] = $ip;
}
$options['cached'] = count($order) - count($pending);
$errors = []; $completed = 0;
if (!$pending) { $options['errors'] = []; return $result; }
foreach (array_chunk($pending, (int) ($options['batch'] ?? PWHOIS_BULK_MAX)) as $n => $chunk) {
$raw = pwhois_transact(pwhois_request_header($options) . "begin\n" . implode("\n", $chunk) . "\nend\n", $options);
if ($raw === null) { $errors[] = 'batch ' . ($n + 1) . ': no answer from the server'; continue; }
$completed++; pwhois_usage_count(count($chunk));
$seen = [];
foreach (pwhois_parse($raw) as $rec) {
$ip = pwhois_normalize_ip((string) ($rec['ip'] ?? ''));
if ($ip === null || !isset($byIp[$ip])) continue;
$val = pwhois_has_content($rec) ? $rec : null; $seen[$ip] = true;
foreach ($byIp[$ip] as $key) $result[$key] = $val;
pwhois_cache_put($ip, 'pwhois', $val);
}
foreach ($chunk as $ip) if (!isset($seen[$ip])) pwhois_cache_put($ip, 'pwhois', null); // the server said nothing: unrouted
}
$options['errors'] = $errors;
return $completed === 0 ? null : $result;
}
/** Canonical text of a valid IPv4/IPv6 address, or null. A trailing /bits is dropped. */
function pwhois_normalize_ip(string $s): ?string
{
$s = trim($s);
if ($s === '') return null;
$s = (string) preg_replace('~/\d{1,3}$~', '', $s);
if (!filter_var($s, FILTER_VALIDATE_IP)) return null;
$bin = @inet_pton($s);
return $bin === false ? null : (string) inet_ntop($bin);
}
/** The app= line that identifies this client in the server's statistics. */
function pwhois_request_header(array $options): string
{
$app = (string) ($options['app'] ?? PWHOIS_APP);
$app = (string) preg_replace('/[^\x20-\x7e]/', '', str_replace('"', '', $app));
return 'app="' . substr($app, 0, 60) . "\"\n";
}
/**
* One connection, one request, the whole answer. Returns the raw text or null.
* The server is reached over IPv4 on purpose: the public service authorizes IPv4
* sources for bulk mode, and a hostname that also has an AAAA record would otherwise
* be tried over IPv6 first.
*/
function pwhois_transact(string $request, array $options = []): ?string
{
$host = (string) ($options['server'] ?? PWHOIS_SERVER);
$port = (int) ($options['port'] ?? PWHOIS_PORT);
$addr = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
if (!filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) return null;
$sock = @stream_socket_client('tcp://' . $addr . ':' . $port, $errno, $errstr, PWHOIS_CONNECT_SECS);
if (!$sock) return null;
stream_set_timeout($sock, PWHOIS_READ_SECS);
if (@fwrite($sock, $request) === false) { fclose($sock); return null; }
$buf = ''; $deadline = microtime(true) + PWHOIS_READ_SECS;
while (!feof($sock) && microtime(true) < $deadline) {
$chunk = fread($sock, 65536);
if ($chunk === false) break;
if ($chunk === '') { $meta = stream_get_meta_data($sock); if (!empty($meta['timed_out'])) break; continue; }
$buf .= $chunk;
if (strlen($buf) > PWHOIS_MAX_BYTES) break;
}
fclose($sock);
return $buf === '' ? null : $buf;
}
/**
* Parse the server's "Field: value" text into records. Records are separated by blank
* lines or by a new "IP:" line. Only the first colon splits a line, so IPv6 values
* survive. Field names are lower-cased with their hyphens kept ("origin-as").
*/
function pwhois_parse(string $raw): array
{
$records = []; $cur = [];
foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) {
if (trim($line) === '') { if ($cur) { $records[] = $cur; $cur = []; } continue; }
if (stripos($line, 'Error:') === 0) continue;
$p = strpos($line, ':');
if ($p === false) continue;
$key = strtolower(trim(substr($line, 0, $p)));
if ($key === '' || !preg_match('/^[a-z0-9 _-]+$/', $key)) continue;
$key = str_replace(' ', '-', $key);
if ($key === 'ip' && $cur) { $records[] = $cur; $cur = []; }
$cur[$key] = pwhois_clean_value(substr($line, $p + 1));
}
if ($cur) $records[] = $cur;
return $records;
}
/** Trim, map the server's NULL sentinel to null, and drop anything that is not printable text. */
function pwhois_clean_value(string $v): ?string
{
$v = trim($v);
if ($v === '' || strcasecmp($v, 'NULL') === 0) return null;
$v = (string) preg_replace('/[^\P{C}]/u', '', $v);
// Registry text is occasionally polluted (e.g. a pasted token after an org name); keep the name, drop the blob.
$v = (string) preg_split('/\s*-{2,}\s*BEGIN\b/i', $v, 2)[0];
$v = (string) preg_replace('/\b[0-9a-fA-F]{32,}\b/', '', $v);
$v = trim((string) preg_replace('/\s{2,}/', ' ', $v));
return $v === '' ? null : $v;
}
/* ---------------------------------------------------------------------------------
Optional 24-hour cache. Off until you enable it.
Backend: SQLite through PDO (part of PHP itself, nothing to install). Every process
and application sharing the file gets real row-level upserts and SQLite's own locking,
so concurrent writers are safe; expiry and least-recently-used eviction are indexed
columns. Where pdo_sqlite is unavailable, a directory of one small file per address
is used instead (atomic renames, no shared write contention).
--------------------------------------------------------------------------------- */
/**
* Enable the cache. $path defaults to <system temp dir>/pwhois-cache.sqlite; give a
* directory (existing, or ending in "/") to force the file-per-address backend. $ttl is
* the lifetime of an answer in seconds (a day); $max the number of addresses kept.
* Negative answers (unrouted space) are cached too, so they are not asked again either.
*/
function pwhois_cache_enable(?string $path = null, int $ttl = 86400, int $max = 20000): void
{
$c = &pwhois_state();
$path = $path ?? rtrim(sys_get_temp_dir(), '/') . '/pwhois-cache.sqlite';
$useDir = is_dir($path) || substr($path, -1) === '/' || !class_exists('PDO') || !in_array('sqlite', PDO::getAvailableDrivers(), true);
if (!$useDir && !is_dir(dirname($path))) @mkdir(dirname($path), 0700, true);
$c['cache'] = ['mode' => $useDir ? 'dir' : 'sqlite', 'path' => rtrim($path, '/'), 'ttl' => max(60, $ttl), 'max' => max(100, $max), 'pdo' => null, 'hits' => 0, 'misses' => 0, 'writes' => 0];
if ($useDir && !is_dir($c['cache']['path'])) @mkdir($c['cache']['path'], 0700, true);
}
function pwhois_cache_disable(): void { $c = &pwhois_state(); $c['cache'] = null; }
/** Hits, misses, writes and entries so far; null when the cache is off. */
function pwhois_cache_stats(): ?array
{
$c = &pwhois_state(); if (!$c['cache']) return null;
$n = 0;
if ($c['cache']['mode'] === 'sqlite') { $pdo = pwhois_cache_pdo(); if ($pdo) { try { $n = (int) $pdo->query('SELECT COUNT(*) FROM pwhois_cache')->fetchColumn(); } catch (Throwable $e) { } } }
else { foreach (new DirectoryIterator($c['cache']['path']) as $f) if ($f->isFile() && substr($f->getFilename(), -5) === '.json') $n++; }
return ['backend' => $c['cache']['mode'], 'path' => $c['cache']['path'], 'ttl' => $c['cache']['ttl'], 'entries' => $n, 'hits' => $c['cache']['hits'], 'misses' => $c['cache']['misses'], 'writes' => $c['cache']['writes']];
}
/** Remove expired rows and, if over $max, the least recently used. Runs automatically about once per 500 writes. */
function pwhois_cache_prune(): void
{
$c = &pwhois_state(); if (!$c['cache']) return;
$now = time(); $cut = $now - $c['cache']['ttl'];
if ($c['cache']['mode'] === 'sqlite') {
$pdo = pwhois_cache_pdo(); if (!$pdo) return;
try {
$pdo->prepare('DELETE FROM pwhois_cache WHERE stored < :cut')->execute([':cut' => $cut]);
$n = (int) $pdo->query('SELECT COUNT(*) FROM pwhois_cache')->fetchColumn();
if ($n > $c['cache']['max']) { $st = $pdo->prepare('DELETE FROM pwhois_cache WHERE k IN (SELECT k FROM pwhois_cache ORDER BY used ASC LIMIT :n)'); $st->bindValue(':n', $n - $c['cache']['max'], PDO::PARAM_INT); $st->execute(); }
} catch (Throwable $e) { }
} else {
$files = [];
foreach (new DirectoryIterator($c['cache']['path']) as $f) { if (!$f->isFile() || substr($f->getFilename(), -5) !== '.json') continue; $m = $f->getMTime(); if ($m < $cut) @unlink($f->getPathname()); else $files[$f->getPathname()] = $m; }
if (count($files) > $c['cache']['max']) { asort($files); foreach (array_slice(array_keys($files), 0, count($files) - $c['cache']['max']) as $fp) @unlink($fp); }
}
}
/** @internal shared state */
function &pwhois_state(): array
{
static $state = ['cache' => null, 'usage' => null];
return $state;
}
/** @internal open (and on first use create) the SQLite cache */
function pwhois_cache_pdo(): ?PDO
{
$c = &pwhois_state(); if (!$c['cache'] || $c['cache']['mode'] !== 'sqlite') return null;
if ($c['cache']['pdo'] instanceof PDO) return $c['cache']['pdo'];
try {
$pdo = new PDO('sqlite:' . $c['cache']['path'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]);
$pdo->exec('PRAGMA journal_mode=WAL'); $pdo->exec('PRAGMA synchronous=NORMAL'); $pdo->exec('PRAGMA busy_timeout=5000');
$pdo->exec('CREATE TABLE IF NOT EXISTS pwhois_cache (k TEXT PRIMARY KEY, stored INTEGER NOT NULL, used INTEGER NOT NULL, rec TEXT)');
$pdo->exec('CREATE INDEX IF NOT EXISTS pwhois_cache_used ON pwhois_cache(used)');
$pdo->exec('CREATE INDEX IF NOT EXISTS pwhois_cache_stored ON pwhois_cache(stored)');
return $c['cache']['pdo'] = $pdo;
} catch (Throwable $e) { $c['cache']['mode'] = 'dir'; $c['cache']['path'] .= '.d'; @mkdir($c['cache']['path'], 0700, true); return null; } // fall back to files
}
/** @internal file name for an entry in directory mode */
function pwhois_cache_file(string $k): string { $c = &pwhois_state(); return $c['cache']['path'] . '/' . hash('sha256', $k) . '.json'; }
/** Returns the cached record (array or null) or false when absent/expired. */
function pwhois_cache_get(string $ip, string $type)
{
$c = &pwhois_state(); if (!$c['cache']) return false;
$k = $type . ':' . $ip; $now = time(); $cut = $now - $c['cache']['ttl'];
if ($c['cache']['mode'] === 'sqlite' && ($pdo = pwhois_cache_pdo())) {
try {
$st = $pdo->prepare('SELECT stored, rec FROM pwhois_cache WHERE k = :k'); $st->execute([':k' => $k]); $row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row || (int) $row['stored'] < $cut) { $c['cache']['misses']++; return false; }
if ($now - (int) ($row['used'] ?? 0) > 60) $pdo->prepare('UPDATE pwhois_cache SET used = :u WHERE k = :k')->execute([':u' => $now, ':k' => $k]);
$c['cache']['hits']++; $rec = json_decode((string) $row['rec'], true); return is_array($rec) ? $rec : null;
} catch (Throwable $e) { $c['cache']['misses']++; return false; }
}
$f = pwhois_cache_file($k);
if (!is_file($f) || (int) @filemtime($f) < $cut) { $c['cache']['misses']++; return false; }
$j = json_decode((string) @file_get_contents($f), true);
if (!is_array($j) || !array_key_exists('r', $j)) { $c['cache']['misses']++; return false; }
@touch($f); $c['cache']['hits']++; return $j['r'];
}
function pwhois_cache_put(string $ip, string $type, ?array $rec): void
{
$c = &pwhois_state(); if (!$c['cache']) return;
$k = $type . ':' . $ip; $now = time(); $c['cache']['writes']++;
if ($c['cache']['mode'] === 'sqlite' && ($pdo = pwhois_cache_pdo())) {
try { $pdo->prepare('INSERT INTO pwhois_cache (k, stored, used, rec) VALUES (:k, :t, :t, :r) ON CONFLICT(k) DO UPDATE SET stored = :t, used = :t, rec = :r')->execute([':k' => $k, ':t' => $now, ':r' => json_encode($rec)]); }
catch (Throwable $e) { }
} else {
$f = pwhois_cache_file($k); $tmp = $f . '.' . getmypid() . '.tmp';
if (@file_put_contents($tmp, json_encode(['r' => $rec])) !== false) @rename($tmp, $f); else @unlink($tmp);
}
if ($c['cache']['writes'] % 500 === 0) pwhois_cache_prune();
}
/* ---------------------------------------------------------------------------------
Allowance: what this process has spent today, and (from the service) what the
address you are querying from has left.
--------------------------------------------------------------------------------- */
/** Queries this process has sent today (UTC), counted locally; cache hits are free. */
function pwhois_usage(): int
{
$c = &pwhois_state(); $day = gmdate('Y-m-d');
if (!is_array($c['usage']) || $c['usage']['day'] !== $day) $c['usage'] = ['day' => $day, 'n' => 0];
return (int) $c['usage']['n'];
}
function pwhois_usage_count(int $n): void { pwhois_usage(); $c = &pwhois_state(); $c['usage']['n'] += $n; }
/**
* Your daily allowance as the service sees it, for the address this call is made from:
* ['address', 'limit', 'used', 'remaining', 'resets_at' (ISO 8601 UTC)]. Free; it is not a
* whois query. Returns null when the service cannot be reached.
*/
function pwhois_quota(array $options = []): ?array
{
$url = (string) ($options['quota_url'] ?? 'https://pwhois.org/tools/quota.php');
$ctx = stream_context_create(['http' => ['method' => 'GET', 'timeout' => 10, 'header' => "User-Agent: " . PWHOIS_USER_AGENT . "\r\nAccept: application/json\r\n"]]);
$j = @file_get_contents($url, false, $ctx);
if ($j === false) return null;
$d = json_decode($j, true);
return (is_array($d) && isset($d['limit'])) ? $d : null;
}
/* ---------------------------------------------------------------------------------
Backward compatibility with the 2005 interface (doPWLookup / doPWLookupBulk).
Those returned keys with the hyphens removed ("originas") and 0 on failure; keep
that shape for existing callers. New code should use the pwhois_* functions.
--------------------------------------------------------------------------------- */
if (!function_exists('doPWLookup')) {
function doPWLookup($query)
{
$r = pwhois_lookup((string) $query, ['app' => 'PWPHPLib']);
if ($r === null) return 0;
$out = []; foreach ($r as $k => $v) $out[str_replace('-', '', $k)] = (string) $v;
return $out;
}
}
if (!function_exists('doPWLookupBulk')) {
function doPWLookupBulk($queryarray)
{
$opts = ['app' => 'PWPHPLib'];
$r = pwhois_lookup_bulk(is_array($queryarray) ? $queryarray : [], $opts);
if ($r === null) return 0;
$out = [];
foreach ($r as $ip => $rec) { if ($rec === null) continue; $flat = []; foreach ($rec as $k => $v) $flat[str_replace('-', '', $k)] = (string) $v; $out[$ip] = $flat; }
return $out;
}
}