<?php
/**
* RIPE NCC RIS whois client library for PHP
*
* A small, dependency-free client for RIPE NCC's Routing Information Service
* (riswhois.ripe.net): the origin AS and prefix for one address, for many addresses in
* one round trip, every covering route of an address, or every prefix an AS announces.
* Answers come back as arrays keyed by the field names the service uses, lower-cased
* ("origin", "descr", "lastupd-last", "num-rispeers", ...), plus a few normalized keys
* ("prefix", "origin-as", "seen-at" as a list).
*
* require 'ris.php';
*
* $one = ris_lookup('4.2.2.1'); // ?array, the most specific route
* $all = ris_lookup_all('8.8.8.8'); // every covering route, most specific first
* $many = ris_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]
* $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 <system temp dir>/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;
}
}