['4.2.2.1' => [...], '2001:4860:4860::8888' => [...], '10.0.0.1' => null] * $pfx = ris_prefixes('AS15169'); // every prefix RIS sees from that origin * * ris_cache_enable(); // optional: 24-hour LRU cache (SQLite, built into PHP) * * RIS is a public service run by the RIPE NCC and publishes no per-address allowance; * the library still never asks the same question twice within a batch and, with the * cache enabled, not twice in a day, out of courtesy to a shared resource. Requires * PHP 7.4 or later (8.x recommended) with stream sockets; nothing else. * * 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 RIS_SERVER = 'riswhois.ripe.net'; const RIS_PORT = 43; const RIS_BULK_MAX = 500; // addresses per persistent connection const RIS_CONNECT_SECS = 10; // connect timeout const RIS_READ_SECS = 30; // overall read deadline per round trip const RIS_MAX_BYTES = 16 << 20; // 16 MB answer cap per round trip (an AS with thousands of prefixes is large) /** * The most specific route RIS sees covering one IPv4 or IPv6 address. * * Returns the record as [field => value], or null when the address is invalid, the * service could not be reached, or no collector sees a covering route. When several * origins announce the same prefix the first is in 'origin' and all of them in 'origins'. * $options: 'server', 'port'. */ function ris_lookup(string $address, array $options = []): ?array { $ip = ris_normalize_ip($address); if ($ip === null) return null; $hit = ris_cache_get('l:' . $ip); if ($hit !== false) return $hit; $raw = ris_transact('-l ' . $ip . "\n", $options); if ($raw === null) return null; ris_usage_count(1); $rec = ris_best_for($ip, ris_parse($raw)); ris_cache_put('l:' . $ip, $rec); return $rec; } /** * Every route RIS sees covering the address, most specific first: the announced /24 * as well as the /12 and /9 above it, for example. Returns a list of records, an * empty list when nothing covers the address, or null on a transport failure. */ function ris_lookup_all(string $address, array $options = []): ?array { $ip = ris_normalize_ip($address); if ($ip === null) return null; $hit = ris_cache_get('L:' . $ip); if ($hit !== false) return $hit; $raw = ris_transact('-L ' . $ip . "\n", $options); if ($raw === null) return null; ris_usage_count(1); $list = []; foreach (ris_merge_moas(ris_parse($raw)) as $rec) { $rec['ip'] = $ip; $list[] = $rec; } usort($list, static fn(array $a, array $b) => ris_prefix_len($b['prefix']) <=> ris_prefix_len($a['prefix'])); ris_cache_put('L:' . $ip, $list); return $list; } /** * Look up many addresses over one persistent connection per 500. Duplicates are * collapsed, invalid entries are reported as null, and the result is keyed by the * address exactly as you passed it. Each address gets its most specific covering route. * * Returns [address => record|null]. Returns null only when no round trip could be * completed at all; a partial failure leaves the missing addresses null and sets * $options['errors'] (by reference) with a message per batch. $options['cached'] tells * you how many answers came from the cache. */ function ris_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 = ris_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; $pending = []; foreach ($order as $ip) { $hit = ris_cache_get('l:' . $ip); 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'] ?? RIS_BULK_MAX)) as $n => $chunk) { $req = "-k\n"; foreach ($chunk as $ip) $req .= '-l ' . $ip . "\n"; $req .= "-k\n"; $raw = ris_transact($req, $options); if ($raw === null) { $errors[] = 'batch ' . ($n + 1) . ': no answer from the service'; continue; } $completed++; ris_usage_count(count($chunk)); // Answers are matched to questions by containment, never by position, so a // missing or multi-record answer can never shift the ones after it. $records = ris_parse($raw); foreach ($chunk as $ip) { $rec = ris_best_for($ip, $records); foreach ($byIp[$ip] as $key) $result[$key] = $rec; ris_cache_put('l:' . $ip, $rec); } } $options['errors'] = $errors; return $completed === 0 ? null : $result; } /** * Every prefix RIS currently sees originated by an AS, IPv4 and IPv6, as a list of * ['prefix' => '8.8.8.0/24', 'origin-as' => 15169, 'num-rispeers' => 375] sorted by * prefix. Accepts 15169 or 'AS15169'. Returns an empty list for an AS that originates * nothing, or null on a transport failure. */ function ris_prefixes($asn, array $options = []): ?array { $n = (int) preg_replace('/^\s*as/i', '', trim((string) $asn)); if ($n <= 0 || $n > 4294967295) return null; $hit = ris_cache_get('as:' . $n); if ($hit !== false) return $hit; $raw = ris_transact('-F -i AS' . $n . "\n", $options); if ($raw === null) return null; ris_usage_count(1); $list = []; foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) { if ($line === '' || $line[0] === '%') continue; $f = preg_split('/\t+/', trim($line)); if (count($f) < 2 || !ris_valid_prefix($f[1])) continue; $list[] = ['prefix' => $f[1], 'origin-as' => (int) $f[0], 'num-rispeers' => isset($f[2]) ? (int) $f[2] : null]; } usort($list, static fn(array $a, array $b) => strnatcmp($a['prefix'], $b['prefix'])); ris_cache_put('as:' . $n, $list); return $list; } /* --------------------------------------------------------------------------------- Parsing and matching --------------------------------------------------------------------------------- */ /** Canonical text of a valid IPv4/IPv6 address, or null. A trailing /bits is dropped. */ function ris_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); } function ris_valid_prefix(string $p): bool { $parts = explode('/', $p, 2); if (count($parts) !== 2 || !ctype_digit($parts[1]) || !filter_var($parts[0], FILTER_VALIDATE_IP)) return false; return (int) $parts[1] <= (strpos($parts[0], ':') !== false ? 128 : 32); } function ris_prefix_len(string $p): int { return (int) (explode('/', $p, 2)[1] ?? 0); } /** True when the binary address falls inside the CIDR prefix (same family required). */ function ris_prefix_contains(string $prefix, string $ipBin): bool { [$net, $len] = explode('/', $prefix, 2) + [1 => '0']; $nb = @inet_pton($net); $len = (int) $len; if ($nb === false || strlen($nb) !== strlen($ipBin)) return false; $bytes = intdiv($len, 8); $bits = $len % 8; if ($bytes && substr($nb, 0, $bytes) !== substr($ipBin, 0, $bytes)) return false; if ($bits === 0) return true; $mask = (0xff << (8 - $bits)) & 0xff; return (ord($nb[$bytes]) & $mask) === (ord($ipBin[$bytes]) & $mask); } /** * Parse RPSL-style "field: value" text into records. Lines beginning with % are * notices from the service and are skipped; a blank line or a new route/route6 line * ends a record. Field names are lower-cased. Adds 'prefix' (route or route6), * 'origin-as' (int) and turns 'seen-at' into a list and 'num-rispeers' into an int. */ function ris_parse(string $raw): array { $records = []; $cur = []; $flush = static function () use (&$records, &$cur): void { if (!$cur) return; $cur['prefix'] = $cur['route'] ?? $cur['route6'] ?? null; if ($cur['prefix'] !== null && ris_valid_prefix($cur['prefix'])) { $cur['origin-as'] = isset($cur['origin']) ? (int) preg_replace('/\D/', '', $cur['origin']) : null; if (isset($cur['seen-at'])) $cur['seen-at'] = array_values(array_filter(array_map('trim', explode(',', $cur['seen-at'])), 'strlen')); if (isset($cur['num-rispeers'])) $cur['num-rispeers'] = (int) $cur['num-rispeers']; $records[] = $cur; } $cur = []; }; foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) { if (trim($line) === '' || $line[0] === '%') { $flush(); continue; } $p = strpos($line, ':'); if ($p === false) continue; $key = strtolower(trim(substr($line, 0, $p))); if ($key === '' || !preg_match('/^[a-z0-9_-]+$/', $key)) continue; if (($key === 'route' || $key === 'route6') && $cur) $flush(); $val = trim(substr($line, $p + 1)); $val = (string) preg_replace('/[^\P{C}]/u', '', $val); $cur[$key] = $val === '' ? null : $val; } $flush(); return $records; } /** Collapse records that share a prefix (several origins announcing it) into one, with 'origins'. */ function ris_merge_moas(array $records): array { $out = []; foreach ($records as $rec) { $k = $rec['prefix']; if (isset($out[$k])) { $out[$k]['origins'][] = $rec['origin']; continue; } $rec['origins'] = [$rec['origin']]; $out[$k] = $rec; } return array_values($out); } /** The most specific parsed record whose prefix contains the address, tagged with 'ip'; null if none. */ function ris_best_for(string $ip, array $records): ?array { $bin = (string) inet_pton($ip); $best = null; $bestLen = -1; foreach (ris_merge_moas($records) as $rec) { if (!ris_prefix_contains($rec['prefix'], $bin)) continue; $len = ris_prefix_len($rec['prefix']); if ($len > $bestLen) { $best = $rec; $bestLen = $len; } } if ($best === null) return null; return ['ip' => $ip] + $best; } /** One connection, one request, the whole answer. Returns the raw text or null. */ function ris_transact(string $request, array $options = []): ?string { $host = (string) ($options['server'] ?? RIS_SERVER); $port = (int) ($options['port'] ?? RIS_PORT); $addr = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host); if (!filter_var($addr, FILTER_VALIDATE_IP)) return null; $sock = @stream_socket_client('tcp://' . $addr . ':' . $port, $errno, $errstr, RIS_CONNECT_SECS); if (!$sock) return null; stream_set_timeout($sock, RIS_READ_SECS); if (@fwrite($sock, $request) === false) { fclose($sock); return null; } $buf = ''; $deadline = microtime(true) + RIS_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) > RIS_MAX_BYTES) break; } fclose($sock); return $buf === '' ? null : $buf; } /* --------------------------------------------------------------------------------- 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 key is used instead (atomic renames, no shared write contention). --------------------------------------------------------------------------------- */ /** * Enable the cache. $path defaults to /ris-cache.sqlite; give a * directory (existing, or ending in "/") to force the file-per-key backend. $ttl is the * lifetime of an answer in seconds (a day); $max the number of keys kept. Negative * answers (nothing covers the address) are cached too. */ function ris_cache_enable(?string $path = null, int $ttl = 86400, int $max = 20000): void { $c = &ris_state(); $path = $path ?? rtrim(sys_get_temp_dir(), '/') . '/ris-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 ris_cache_disable(): void { $c = &ris_state(); $c['cache'] = null; } /** Hits, misses, writes and entries so far; null when the cache is off. */ function ris_cache_stats(): ?array { $c = &ris_state(); if (!$c['cache']) return null; $n = 0; if ($c['cache']['mode'] === 'sqlite') { $pdo = ris_cache_pdo(); if ($pdo) { try { $n = (int) $pdo->query('SELECT COUNT(*) FROM ris_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 ris_cache_prune(): void { $c = &ris_state(); if (!$c['cache']) return; $now = time(); $cut = $now - $c['cache']['ttl']; if ($c['cache']['mode'] === 'sqlite') { $pdo = ris_cache_pdo(); if (!$pdo) return; try { $pdo->prepare('DELETE FROM ris_cache WHERE stored < :cut')->execute([':cut' => $cut]); $n = (int) $pdo->query('SELECT COUNT(*) FROM ris_cache')->fetchColumn(); if ($n > $c['cache']['max']) { $st = $pdo->prepare('DELETE FROM ris_cache WHERE k IN (SELECT k FROM ris_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 &ris_state(): array { static $state = ['cache' => null, 'usage' => null]; return $state; } /** @internal open (and on first use create) the SQLite cache */ function ris_cache_pdo(): ?PDO { $c = &ris_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 ris_cache (k TEXT PRIMARY KEY, stored INTEGER NOT NULL, used INTEGER NOT NULL, rec TEXT)'); $pdo->exec('CREATE INDEX IF NOT EXISTS ris_cache_used ON ris_cache(used)'); $pdo->exec('CREATE INDEX IF NOT EXISTS ris_cache_stored ON ris_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 ris_cache_file(string $k): string { $c = &ris_state(); return $c['cache']['path'] . '/' . hash('sha256', $k) . '.json'; } /** Returns the cached value (array or null) or false when absent/expired. */ function ris_cache_get(string $k) { $c = &ris_state(); if (!$c['cache']) return false; $now = time(); $cut = $now - $c['cache']['ttl']; if ($c['cache']['mode'] === 'sqlite' && ($pdo = ris_cache_pdo())) { try { $st = $pdo->prepare('SELECT stored, used, rec FROM ris_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'] > 60) $pdo->prepare('UPDATE ris_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 = ris_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 ris_cache_put(string $k, ?array $val): void { $c = &ris_state(); if (!$c['cache']) return; $now = time(); $c['cache']['writes']++; if ($c['cache']['mode'] === 'sqlite' && ($pdo = ris_cache_pdo())) { try { $pdo->prepare('INSERT INTO ris_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($val)]); } catch (Throwable $e) { } } else { $f = ris_cache_file($k); $tmp = $f . '.' . getmypid() . '.tmp'; if (@file_put_contents($tmp, json_encode(['r' => $val])) !== false) @rename($tmp, $f); else @unlink($tmp); } if ($c['cache']['writes'] % 500 === 0) ris_cache_prune(); } /* --------------------------------------------------------------------------------- Usage: what this process has asked the service today. RIS publishes no allowance, so this is a courtesy meter rather than a budget. --------------------------------------------------------------------------------- */ /** Queries this process has sent today (UTC), counted locally; cache hits are free. */ function ris_usage(): int { $c = &ris_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 ris_usage_count(int $n): void { ris_usage(); $c = &ris_state(); $c['usage']['n'] += $n; } /* --------------------------------------------------------------------------------- Backward compatibility with the 2005 interface (doRISLookupBulk). It returned, per address that had an answer, 'route', 'origin' (digits only), 'descr', 'lastupd-frst', 'lastupd-last', 'seen-at' (comma string), 'num-rispeers' and 'source', and 0 on failure. That shape is kept for existing callers; new code should use ris_*. --------------------------------------------------------------------------------- */ if (!function_exists('doRISLookupBulk')) { function doRISLookupBulk($queryarray) { $opts = []; $r = ris_lookup_bulk(is_array($queryarray) ? $queryarray : [], $opts); if ($r === null) return 0; $out = []; foreach ($r as $ip => $rec) { if ($rec === null) continue; $flat = ['route' => (string) $rec['prefix'], 'origin' => (string) ($rec['origin-as'] ?? '')]; foreach (['descr', 'lastupd-frst', 'lastupd-last', 'source'] as $k) if (isset($rec[$k])) $flat[$k] = (string) $rec[$k]; if (isset($rec['seen-at'])) $flat['seen-at'] = implode(',', (array) $rec['seen-at']); if (isset($rec['num-rispeers'])) $flat['num-rispeers'] = (string) $rec['num-rispeers']; $out[$ip] = $flat; } return $out; } }