pwhois.phpback to the library pagedownload
<?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;
    }
}