['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 /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; } }