Gestire le query WHOIS in PHP
PHP viene spesso associato al ciclo richiesta-risposta HTTP, ma dispone di un'API per i socket matura e sorprendentemente flessibile. È quindi un ottimo linguaggio per scrivere un client WHOIS: le funzioni sugli stream permettono di aprire connessioni TCP con timeout precisi, di leggere dati in modo controllato e perfino di gestire più connessioni in parallelo senza estensioni aggiuntive. In questo articolo costruiamo un client WHOIS in PHP 8.3 organizzato in classi, con validazione dei domini internazionalizzati, gestione dei referral, parsing delle risposte, cache su file e query parallele tramite stream_select().
Un'avvertenza sullo stato del protocollo. Dal 28 gennaio 2025 ICANN non obbliga più registry e registrar dei domini generici (gTLD) a offrire WHOIS sulla porta 43, e il protocollo di riferimento per quei domini è diventato RDAP. WHOIS resta però molto usato: gran parte dei registry nazionali, compresi quelli di .it e .de, lo mantiene come servizio principale, e diversi registry generici, tra cui quello del .com, continuano a offrirlo. Il client che scriveremo deve quindi funzionare bene, ma anche fallire con garbo quando un server non risponde più.
Il protocollo in breve
La RFC 3912 descrive WHOIS in poche righe. Il client si connette alla porta TCP 43 del server, invia la query terminata da \r\n e legge la risposta fino a quando il server chiude la connessione. Non ci sono intestazioni, non c'è una lunghezza dichiarata, non c'è una codifica dei caratteri dichiarata e non c'è un formato strutturato. La chiusura del socket è l'unico segnale di fine risposta.
Ne derivano quattro requisiti per un client affidabile: timeout sia sulla connessione sia sulla lettura, un limite alla dimensione della risposta, una strategia di decodifica con ripiego e un parser tollerante verso formati diversi. A questi si aggiunge la ricerca del server corretto: si parte da whois.iana.org, che indica il server del registry del TLD, e talvolta si prosegue verso il server del registrar indicato nella risposta del registry.
Struttura del progetto
Organizziamo il codice come una piccola libreria con autoload PSR-4 gestito da Composer. Il file composer.json dichiara la versione minima di PHP e le estensioni necessarie: intl per la conversione dei domini internazionalizzati e mbstring per la gestione della codifica.
{
"name": "example/whois-client",
"type": "library",
"require": {
"php": "^8.3",
"ext-intl": "*",
"ext-mbstring": "*"
},
"autoload": {
"psr-4": {
"Whois\\": "src/"
}
}
}
La libreria sarà composta da poche classi, ciascuna con una responsabilità precisa:
WhoisException, l'eccezione specifica della libreria;DomainName, un oggetto valore che normalizza e valida il dominio;SocketTransport, che esegue le query di rete;WhoisParser, che interpreta il testo delle risposte;WhoisClient, che coordina referral, cache e parsing.
L'eccezione e l'oggetto valore per il dominio
Iniziamo dall'eccezione, che conserva il nome del server coinvolto quando l'errore è di rete. Questo permette a chi la intercetta di distinguere un errore di input, in cui il server è assente, da un errore remoto.
<?php
declare(strict_types=1);
namespace Whois;
use RuntimeException;
use Throwable;
final class WhoisException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?string $server = null,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
La normalizzazione del dominio è un passaggio di sicurezza oltre che di correttezza. La query WHOIS è una riga terminata da \r\n: un input che contenesse a sua volta un'interruzione di riga permetterebbe di iniettare comandi aggiuntivi verso il server. Inoltre i domini internazionalizzati, come caffè.it, vanno convertiti in Punycode, cioè xn--caff-8oa.it, prima di essere inviati. La funzione idn_to_ascii() dell'estensione intl, usata con la variante UTS #46, si occupa della conversione e restituisce false se il dominio non è valido.
<?php
declare(strict_types=1);
namespace Whois;
final class DomainName
{
private function __construct(public readonly string $ascii)
{
}
public static function fromString(string $input): self
{
// Rimuove spazi, maiuscole e l'eventuale punto finale
$domain = rtrim(mb_strtolower(trim($input)), '.');
$ascii = idn_to_ascii(
$domain,
IDNA_NONTRANSITIONAL_TO_ASCII | IDNA_USE_STD3_RULES,
INTL_IDNA_VARIANT_UTS46,
);
// Dopo la conversione sono ammessi solo lettere minuscole, cifre, punti e trattini
if ($ascii === false || preg_match('/^[a-z0-9.-]+$/', $ascii) !== 1) {
throw new WhoisException("Dominio non valido: {$input}");
}
$labels = explode('.', $ascii);
if (count($labels) < 2 || strlen($ascii) > 253) {
throw new WhoisException("Dominio non valido: {$input}");
}
foreach ($labels as $label) {
$length = strlen($label);
if ($length === 0 || $length > 63 || str_starts_with($label, '-') || str_ends_with($label, '-')) {
throw new WhoisException("Etichetta non valida nel dominio: {$input}");
}
}
return new self($ascii);
}
public function tld(): string
{
return substr($this->ascii, strrpos($this->ascii, '.') + 1);
}
public function __toString(): string
{
return $this->ascii;
}
}
Il costruttore privato obbliga a passare dal metodo fromString(): un'istanza di DomainName è quindi, per costruzione, sempre valida, e il resto del codice può fidarsi della sua proprietà $ascii senza ripetere controlli.
Il trasporto: una query con timeout e limiti
La funzione stream_socket_client() apre la connessione TCP accettando un timeout di connessione. Il timeout di lettura si imposta invece con stream_set_timeout(): quando scade, fread() restituisce una stringa vuota e la chiave timed_out di stream_get_meta_data() diventa vera. Come per qualsiasi client WHOIS, al timeout di inattività va affiancata una scadenza complessiva, perché un server che invia un byte a intervalli regolari non farebbe mai scattare il primo.
<?php
declare(strict_types=1);
namespace Whois;
final class SocketTransport
{
public const PORT = 43;
public function __construct(
private readonly float $timeout = 10.0,
private readonly float $deadline = 30.0,
private readonly int $maxBytes = 1_048_576,
) {
}
public function query(string $server, string $query): string
{
$socket = @stream_socket_client(
"tcp://{$server}:" . self::PORT,
$errorCode,
$errorMessage,
$this->timeout,
);
if ($socket === false) {
throw new WhoisException("Connessione fallita ({$errorCode}): {$errorMessage}", $server);
}
try {
$seconds = (int) $this->timeout;
stream_set_timeout($socket, $seconds, (int) (($this->timeout - $seconds) * 1_000_000));
if (@fwrite($socket, $query . "\r\n") === false) {
throw new WhoisException('Invio della query fallito', $server);
}
$buffer = '';
$deadlineAt = microtime(true) + $this->deadline;
while (!feof($socket)) {
$chunk = fread($socket, 8192);
if (stream_get_meta_data($socket)['timed_out']) {
throw new WhoisException("Nessuna risposta per {$this->timeout} secondi", $server);
}
if ($chunk === false) {
throw new WhoisException('Errore durante la lettura', $server);
}
$buffer .= $chunk;
if (strlen($buffer) > $this->maxBytes) {
throw new WhoisException("Risposta oltre il limite di {$this->maxBytes} byte", $server);
}
if (microtime(true) > $deadlineAt) {
throw new WhoisException("Scadenza di {$this->deadline} secondi superata", $server);
}
}
return self::decode($buffer);
} finally {
fclose($socket);
}
}
public static function decode(string $bytes): string
{
// Se i byte non sono UTF-8 valido, li si interpreta come Latin-1
return mb_check_encoding($bytes, 'UTF-8')
? $bytes
: mb_convert_encoding($bytes, 'UTF-8', 'ISO-8859-1');
}
}
Il blocco finally garantisce che il socket venga chiuso anche quando viene sollevata un'eccezione. La decodifica sfrutta il fatto che le stringhe PHP sono sequenze di byte: se la risposta è già UTF-8 valido la si restituisce così com'è, altrimenti la si converte da Latin-1, la codifica storicamente più diffusa tra i server che non usano UTF-8. Latin-1 associa un carattere a ogni possibile byte, quindi la conversione non fallisce mai.
Una prova rapida dalla riga di comando conferma che il trasporto funziona:
<?php
require __DIR__ . '/vendor/autoload.php';
use Whois\SocketTransport;
$transport = new SocketTransport(timeout: 5.0);
// Il server di Verisign accetta la parola chiave "domain" per limitare la ricerca ai domini
echo $transport->query('whois.verisign-grs.com', 'domain example.com');
Interpretare le risposte
Le risposte WHOIS non seguono uno standard, ma rientrano quasi sempre in due famiglie. La più comune usa righe Chiave: valore, con chiavi ripetute per i valori multipli come i name server. L'altra, adottata ad esempio dal registry italiano, organizza i dati in blocchi: un'intestazione senza due punti, come Nameservers o Registrar, seguita da righe rientrate fino alla successiva riga vuota. Il parser che segue gestisce entrambe con un'unica scansione.
<?php
declare(strict_types=1);
namespace Whois;
final class WhoisParser
{
private const COMMENT_PREFIXES = ['%', '#', '>>>'];
private const FIELD_ALIASES = [
'registrar' => ['registrar', 'registrar.organization', 'registrar.name', 'sponsoring registrar'],
'created_at' => ['creation date', 'created', 'created on', 'registered on', 'registration time'],
'updated_at' => ['updated date', 'last update', 'last updated', 'changed', 'modified'],
'expires_at' => [
'registry expiry date',
'registrar registration expiration date',
'expiry date',
'expire date',
'expire',
'expires',
'expiration time',
],
'name_servers' => ['name server', 'nameservers', 'nserver'],
'status' => ['domain status', 'status', 'state'],
];
private const NOT_FOUND_PATTERNS = [
'/^no match for/im',
'/^not found/im',
'/^status:\s*(available|free)\b/im',
'/no data found/i',
'/no entries found/i',
'/domain not found/i',
];
/**
* @return array<string, list<string>>
*/
public function fields(string $text): array
{
$fields = [];
$section = null;
foreach (preg_split('/\r?\n/', $text) as $rawLine) {
$line = trim($rawLine);
// Una riga vuota chiude il blocco corrente
if ($line === '') {
$section = null;
continue;
}
if ($this->isComment($line)) {
continue;
}
$separator = strpos($line, ':');
if ($separator === false || $separator === 0) {
// Valore del blocco corrente oppure intestazione di un nuovo blocco
if ($section !== null) {
$fields[$section][] = $line;
} else {
$section = strtolower($line);
}
continue;
}
$key = strtolower(trim(substr($line, 0, $separator)));
$value = trim(substr($line, $separator + 1));
if ($value === '') {
$section = $key;
continue;
}
$fields[$section !== null ? "{$section}.{$key}" : $key][] = $value;
}
return $fields;
}
/**
* @return array<string, mixed>
*/
public function record(DomainName $domain, string $text): array
{
$fields = $this->fields($text);
return [
'domain' => $domain->ascii,
'available' => $this->isAvailable($text),
'registrar' => $this->first($fields, self::FIELD_ALIASES['registrar']),
'created_at' => $this->first($fields, self::FIELD_ALIASES['created_at']),
'updated_at' => $this->first($fields, self::FIELD_ALIASES['updated_at']),
'expires_at' => $this->first($fields, self::FIELD_ALIASES['expires_at']),
'name_servers' => $this->all($fields, self::FIELD_ALIASES['name_servers']),
'status' => $this->all($fields, self::FIELD_ALIASES['status']),
];
}
public function isAvailable(string $text): bool
{
foreach (self::NOT_FOUND_PATTERNS as $pattern) {
if (preg_match($pattern, $text) === 1) {
return true;
}
}
return false;
}
public function referral(string $text): ?string
{
$patterns = [
'/^\s*refer:\s*(\S+)/im',
'/^\s*whois:\s*(\S+)/im',
'/^\s*Registrar WHOIS Server:\s*(\S+)/im',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $text, $matches) === 1) {
// Alcuni registrar indicano un URL: teniamo solo l'hostname
$host = strtolower(preg_replace(['#^[a-z]+://#i', '#[/:].*$#'], '', $matches[1]));
if (preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $host) === 1) {
return $host;
}
}
}
return null;
}
private function isComment(string $line): bool
{
foreach (self::COMMENT_PREFIXES as $prefix) {
if (str_starts_with($line, $prefix)) {
return true;
}
}
return false;
}
/**
* @param array<string, list<string>> $fields
* @param list<string> $aliases
*/
private function first(array $fields, array $aliases): ?string
{
foreach ($aliases as $alias) {
if (isset($fields[$alias][0])) {
return $fields[$alias][0];
}
}
return null;
}
/**
* @param array<string, list<string>> $fields
* @param list<string> $aliases
* @return list<string>
*/
private function all(array $fields, array $aliases): array
{
$values = [];
foreach ($aliases as $alias) {
foreach ($fields[$alias] ?? [] as $value) {
// Alcuni server aggiungono IP o URL dopo il valore: teniamo il primo token
$values[] = strtolower(preg_split('/\s+/', $value)[0]);
}
}
return array_values(array_unique($values));
}
}
Il controllo di disponibilità lavora sul testo grezzo, perché i messaggi che segnalano un dominio libero sono spesso commenti che il parser scarta. Resta un'euristica: solo il registrar, al momento della registrazione, può confermare che un dominio è davvero libero. Allo stesso modo le date vengono restituite come stringhe, dato che ogni registry usa un formato proprio; convertirle con DateTimeImmutable ha senso solo dopo aver stabilito il formato atteso per ciascun registry.
Il client: referral e cache
Il client mette insieme i pezzi. Il percorso di ricerca parte da whois.iana.org, segue il campo refer: verso il server del registry e, se la risposta del registry indica un Registrar WHOIS Server, prosegue verso il registrar. Il client memorizza il server associato a ogni TLD per non interrogare IANA a ogni ricerca, tiene traccia dei server già visitati per evitare cicli, impone un numero massimo di passaggi e non considera fatale il fallimento del solo registrar, che dopo la dismissione dell'obbligo contrattuale capita sempre più spesso.
Per la cache usiamo una soluzione volutamente semplice: un file JSON per ogni dominio in una directory temporanea, con la data di scadenza al suo interno. In un'applicazione reale si userebbe un'implementazione di PSR-16, APCu o Redis, ma la logica non cambia.
<?php
declare(strict_types=1);
namespace Whois;
final class WhoisClient
{
public const IANA_SERVER = 'whois.iana.org';
private const MAX_HOPS = 4;
private const QUERY_TEMPLATES = [
'whois.verisign-grs.com' => 'domain %s',
];
/** @var array<string, string> */
private array $tldServers = [];
public function __construct(
private readonly SocketTransport $transport = new SocketTransport(),
private readonly WhoisParser $parser = new WhoisParser(),
private readonly ?string $cacheDir = null,
private readonly int $ttl = 3600,
) {
}
/**
* @return array<string, mixed>
*/
public function lookup(string $input, bool $fresh = false): array
{
$domain = DomainName::fromString($input);
if (!$fresh && ($cached = $this->readCache($domain)) !== null) {
return $cached;
}
$chain = $this->resolve($domain);
$answers = array_values(array_filter(
$chain,
static fn (array $step): bool => isset($step['text']) && $step['server'] !== self::IANA_SERVER,
));
if ($answers === []) {
throw new WhoisException("Nessun server WHOIS trovato per {$domain}", self::IANA_SERVER);
}
$text = end($answers)['text'];
$record = $this->parser->record($domain, $text) + [
'servers' => array_column($chain, 'server'),
'raw' => $text,
];
$this->writeCache($domain, $record);
return $record;
}
public function buildQuery(string $server, DomainName $domain): string
{
return sprintf(self::QUERY_TEMPLATES[$server] ?? '%s', $domain->ascii);
}
public function serverFor(DomainName $domain): ?string
{
$tld = $domain->tld();
if (!isset($this->tldServers[$tld])) {
$text = $this->transport->query(self::IANA_SERVER, $domain->ascii);
$server = $this->parser->referral($text);
if ($server === null) {
return null;
}
$this->tldServers[$tld] = $server;
}
return $this->tldServers[$tld];
}
/**
* @return list<array{server: string, text?: string, error?: string}>
*/
private function resolve(DomainName $domain): array
{
$server = $this->serverFor($domain);
$chain = [];
$visited = [];
while ($server !== null && count($chain) < self::MAX_HOPS && !isset($visited[$server])) {
$visited[$server] = true;
try {
$text = $this->transport->query($server, $this->buildQuery($server, $domain));
} catch (WhoisException $e) {
// Con una risposta del registry già in mano, un registrar muto non è fatale
if ($chain !== []) {
$chain[] = ['server' => $server, 'error' => $e->getMessage()];
break;
}
throw $e;
}
$chain[] = ['server' => $server, 'text' => $text];
$server = $this->parser->referral($text);
}
return $chain;
}
private function cachePath(DomainName $domain): ?string
{
return $this->cacheDir === null
? null
: rtrim($this->cacheDir, '/') . '/' . hash('sha256', $domain->ascii) . '.json';
}
/**
* @return array<string, mixed>|null
*/
private function readCache(DomainName $domain): ?array
{
$path = $this->cachePath($domain);
if ($path === null || !is_file($path)) {
return null;
}
$entry = json_decode((string) file_get_contents($path), true);
if (!is_array($entry) || ($entry['expires'] ?? 0) < time()) {
return null;
}
return $entry['record'];
}
/**
* @param array<string, mixed> $record
*/
private function writeCache(DomainName $domain, array $record): void
{
$path = $this->cachePath($domain);
if ($path === null) {
return;
}
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0700, true);
}
// Scrittura atomica: file temporaneo e rinomina
$temporary = $path . '.' . bin2hex(random_bytes(4));
file_put_contents($temporary, json_encode(
['expires' => time() + $this->ttl, 'record' => $record],
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
));
rename($temporary, $path);
}
}
Due dettagli meritano un commento. Il nome del file di cache è un hash SHA-256 del dominio: anche se il dominio è già validato, usare un hash evita qualsiasi ragionamento su caratteri speciali nei percorsi. La scrittura avviene su un file temporaneo poi rinominato, perché su filesystem POSIX la rinomina è atomica e un processo concorrente non leggerà mai un file scritto a metà.
Si noti anche che nel percorso di referral la risposta di IANA non entra nella catena: il metodo serverFor() la usa solo per ricavare il server del registry, che viene memorizzato nella proprietà $tldServers. Poiché in PHP ogni richiesta web parte da zero, questa memoria vale per la durata di uno script; per renderla persistente tra le richieste basta salvarla nella stessa cache su file.
Query parallele con stream_select
Quando bisogna verificare molti domini, ad esempio per controllare la disponibilità di un elenco di nomi, eseguire le query una dopo l'altra è lento: ogni query attende la latenza di rete del server. PHP consente di gestire più socket contemporaneamente con stream_select(), che attende finché almeno uno dei socket è pronto in lettura o in scrittura. Combinando l'opzione STREAM_CLIENT_ASYNC_CONNECT con socket non bloccanti si ottiene un piccolo ciclo di eventi.
Il metodo seguente, da aggiungere a SocketTransport, accetta un array associativo di coppie server-query e restituisce un array con le stesse chiavi, contenente per ciascuna la risposta oppure l'eccezione che l'ha fatta fallire.
/**
* @param array<string, array{0: string, 1: string}> $requests chiave => [server, query]
* @return array<string, string|WhoisException>
*/
public function queryMany(array $requests): array
{
$results = [];
$pending = [];
$keyBySocket = [];
foreach ($requests as $key => [$server, $query]) {
$socket = @stream_socket_client(
"tcp://{$server}:" . self::PORT,
$errorCode,
$errorMessage,
$this->timeout,
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT,
);
if ($socket === false) {
$results[$key] = new WhoisException("Connessione fallita: {$errorMessage}", $server);
continue;
}
stream_set_blocking($socket, false);
$pending[$key] = ['socket' => $socket, 'server' => $server, 'out' => $query . "\r\n", 'in' => ''];
$keyBySocket[(int) $socket] = $key;
}
$deadlineAt = microtime(true) + $this->deadline;
while ($pending !== []) {
$remaining = $deadlineAt - microtime(true);
if ($remaining <= 0) {
break;
}
// Finché la query non è stata inviata si attende la scrittura, poi la lettura
$read = [];
$write = [];
foreach ($pending as $state) {
if ($state['out'] !== '') {
$write[] = $state['socket'];
} else {
$read[] = $state['socket'];
}
}
$except = null;
$seconds = (int) $remaining;
$ready = @stream_select($read, $write, $except, $seconds, (int) (($remaining - $seconds) * 1_000_000));
if ($ready === false) {
break;
}
foreach ($write as $socket) {
$key = $keyBySocket[(int) $socket];
$written = @fwrite($socket, $pending[$key]['out']);
if ($written === false) {
$results[$key] = new WhoisException('Invio fallito', $pending[$key]['server']);
fclose($socket);
unset($pending[$key]);
continue;
}
$pending[$key]['out'] = substr($pending[$key]['out'], $written);
}
foreach ($read as $socket) {
$key = $keyBySocket[(int) $socket];
$chunk = @fread($socket, 8192);
if ($chunk === false || ($chunk === '' && feof($socket))) {
// Il server ha chiuso la connessione: la risposta è completa
$results[$key] = self::decode($pending[$key]['in']);
fclose($socket);
unset($pending[$key]);
continue;
}
$pending[$key]['in'] .= $chunk;
if (strlen($pending[$key]['in']) > $this->maxBytes) {
$results[$key] = new WhoisException('Risposta troppo grande', $pending[$key]['server']);
fclose($socket);
unset($pending[$key]);
}
}
}
// Ciò che resta in sospeso ha superato la scadenza complessiva
foreach ($pending as $key => $state) {
$results[$key] = new WhoisException('Scadenza superata', $state['server']);
fclose($state['socket']);
}
return $results;
}
La mappa $keyBySocket collega ogni socket alla chiave della richiesta usando l'identificativo numerico della risorsa, ottenuto con il cast a intero. In questo modo non dipendiamo da come stream_select() tratta le chiavi degli array che riceve e modifica. Il ciclo termina quando tutte le richieste sono concluse oppure quando scade il tempo complessivo; in quest'ultimo caso le richieste rimaste vengono chiuse e marcate come fallite.
Con queryMany() possiamo scrivere un controllo di disponibilità in blocco. Per questo caso d'uso non serve seguire il referral fino al registrar: la risposta del registry è sufficiente a capire se il dominio è registrato.
/**
* Metodo di WhoisClient: verifica la disponibilità di più domini in parallelo.
*
* @param list<string> $inputs
* @return array<string, bool|null> null indica un errore per quel dominio
*/
public function availability(array $inputs): array
{
$requests = [];
foreach ($inputs as $input) {
$domain = DomainName::fromString($input);
$server = $this->serverFor($domain);
if ($server !== null) {
$requests[$domain->ascii] = [$server, $this->buildQuery($server, $domain)];
}
}
$result = [];
foreach ($this->transport->queryMany($requests) as $domain => $response) {
$result[$domain] = $response instanceof WhoisException
? null
: $this->parser->isAvailable($response);
}
return $result;
}
Va usato con giudizio: inviare decine di query simultanee allo stesso server è il modo più rapido per farsi bloccare. È buona norma suddividere l'elenco in piccoli lotti e inserire una pausa tra un lotto e l'altro, oppure raggruppare le richieste per server e limitare il numero di connessioni contemporanee verso ciascuno.
Un endpoint JSON
Per esporre il client via HTTP basta un piccolo script. Il codice di stato distingue gli errori di input, per i quali l'eccezione non ha un server associato, dagli errori dei server remoti.
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Whois\SocketTransport;
use Whois\WhoisClient;
use Whois\WhoisException;
header('Content-Type: application/json; charset=utf-8');
$domain = $_GET['domain'] ?? null;
if (!is_string($domain) || $domain === '') {
http_response_code(400);
echo json_encode(['error' => 'Parametro domain mancante']);
exit;
}
$client = new WhoisClient(
transport: new SocketTransport(timeout: 8.0),
cacheDir: sys_get_temp_dir() . '/whois-cache',
);
try {
$record = $client->lookup($domain, fresh: isset($_GET['fresh']));
if (!isset($_GET['raw'])) {
unset($record['raw']);
}
echo json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (WhoisException $e) {
// Senza server associato l'errore riguarda l'input, altrimenti un server remoto
http_response_code($e->server === null ? 422 : 502);
echo json_encode(['error' => $e->getMessage(), 'server' => $e->server]);
}
Lo si può provare con il server integrato di PHP:
php -S 127.0.0.1:8080 -t public
curl 'http://127.0.0.1:8080/?domain=example.com'
Limitare la frequenza delle richieste
I server WHOIS impongono limiti di frequenza raramente documentati. In uno script a riga di comando è semplice rispettarli: si tiene traccia dell'ultimo istante in cui è stato contattato ciascun server e, se necessario, si attende con usleep() prima della richiesta successiva. In un'applicazione web la situazione è diversa, perché ogni richiesta viene servita da un processo distinto che non condivide memoria con gli altri: il limitatore deve quindi vivere in un archivio condiviso come APCu, che però è locale alla singola macchina, o meglio Redis. Un framework come Laravel offre questi strumenti già pronti, ed è il motivo per cui l'articolo della serie dedicato a Laravel riprende questo client integrandolo con cache, lock distribuiti e rate limiter del framework.
Considerazioni finali
Il client che abbiamo costruito gestisce i casi più comuni, ma alcune questioni vanno tenute presenti. Per i domini generici RDAP è il protocollo di riferimento: usa HTTPS, restituisce JSON strutturato e prevede un registro di bootstrap pubblicato da IANA per individuare il server giusto. Un client moderno dovrebbe tentare prima RDAP e ricorrere a WHOIS per i TLD che non lo supportano.
Dopo l'entrata in vigore del GDPR molti dati personali degli intestatari sono oscurati, e diciture come REDACTED FOR PRIVACY sono la norma, non un errore. Infine, le condizioni d'uso dei server WHOIS vietano in genere l'interrogazione massiva e l'uso dei dati a fini commerciali: rispettarle, insieme ai limiti di frequenza e a una cache generosa, è ciò che distingue un client utile da un client che viene bloccato.