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