Effettuare richieste WHOIS in Laravel
Il protocollo WHOIS è uno dei più antichi strumenti di interrogazione disponibili su Internet. Permette di ottenere informazioni di registrazione relative a domini, indirizzi IP e sistemi autonomi (AS). In un'applicazione Laravel, è possibile integrare questa funzionalità in modo pulito ed efficace, sfruttando sia le librerie di terze parti disponibili tramite Composer, sia implementazioni personalizzate basate sui socket TCP di PHP. In questo articolo vedremo come strutturare una soluzione completa: dall'installazione delle dipendenze, alla creazione di un servizio dedicato, fino all'esposizione dei dati tramite un controller e una route API.
Come funziona il protocollo WHOIS
WHOIS è un protocollo di tipo client-server basato su TCP, definito originariamente nella RFC 954 e successivamente aggiornato dalla RFC 3912. Il client apre una connessione sulla porta 43 verso un server WHOIS (ad esempio whois.verisign-grs.com per i domini .com, oppure whois.iana.org come punto di partenza generico), invia il nome di dominio o l'indirizzo IP da interrogare seguito da un carattere di nuova riga, e legge la risposta in testo semplice.
La risposta è un testo non strutturato, il cui formato varia da registro a registro. Alcuni campi comuni includono il nome del registrar, le date di creazione e scadenza del dominio, i nameserver, e le informazioni di contatto del titolare (laddove non oscurate dal GDPR o da privacy shield). Questa variabilità è la principale sfida nell'analisi programmatica delle risposte WHOIS.
Approcci disponibili in Laravel
In un contesto Laravel esistono essenzialmente tre approcci per effettuare richieste WHOIS:
Il primo consiste nell'uso della libreria io-developer/php-whois, un pacchetto PHP attivamente mantenuto che gestisce automaticamente la selezione del server WHOIS corretto in base al TLD e offre un parsing parziale della risposta.
Il secondo approccio prevede l'implementazione manuale tramite socket PHP, utile quando si vuole il controllo completo sul processo o quando si vogliono evitare dipendenze esterne.
Il terzo, infine, consiste nell'esecuzione del comando di sistema whois tramite proc_open o la funzione shell_exec, soluzione adatta solo in ambienti controllati dove il binario è garantito disponibile.
In questo articolo approfondiremo il primo e il secondo approccio, che sono i più adatti per applicazioni Laravel in produzione.
Installazione della libreria io-developer/php-whois
Dalla root del progetto Laravel, installiamo il pacchetto tramite Composer:
composer require io-developer/php-whois
Una volta completata l'installazione, il pacchetto è immediatamente disponibile nel namespace Iodev\Whois. Non è richiesta alcuna pubblicazione di configurazione o registrazione manuale del service provider, in quanto la libreria è una classe PHP pura senza integrazione specifica con il framework.
Creazione del servizio WHOIS
Seguendo i principi SOLID e le convenzioni di Laravel, è buona pratica incapsulare la logica di interrogazione WHOIS in un service class dedicato. Creiamo la directory app/Services se non esiste, e al suo interno il file WhoisService.php:
<?php
namespace App\Services;
use Iodev\Whois\Factory;
use Iodev\Whois\Exceptions\ConnectionException;
use Iodev\Whois\Exceptions\ServerMismatchException;
use Iodev\Whois\Exceptions\WhoisException;
class WhoisService
{
// Istanza del client WHOIS
protected $client;
public function __construct()
{
// Creiamo il client usando il factory predefinito
$this->client = Factory::get()->createWhois();
}
/**
* Esegue una ricerca WHOIS per un dominio.
*
* @param string $domain
* @return array
*/
public function lookup(string $domain): array
{
try {
// Effettuiamo la ricerca e otteniamo le informazioni
$info = $this->client->loadDomainInfo($domain);
if ($info === null) {
return [
'success' => false,
'message' => 'Nessuna informazione trovata per il dominio specificato.',
'data' => null,
];
}
// Restituiamo i dati strutturati in un array
return [
'success' => true,
'message' => 'OK',
'data' => [
'domain' => $info->getDomainName(),
'registrar' => $info->getRegistrar(),
'created_at' => $info->getCreationDate()
? date('Y-m-d H:i:s', $info->getCreationDate())
: null,
'updated_at' => $info->getUpdatedDate()
? date('Y-m-d H:i:s', $info->getUpdatedDate())
: null,
'expires_at' => $info->getExpirationDate()
? date('Y-m-d H:i:s', $info->getExpirationDate())
: null,
'nameservers' => $info->getNameServers(),
'states' => $info->getStates(),
'owner' => $info->getOwner(),
],
];
} catch (ConnectionException $e) {
// Errore di connessione al server WHOIS
return [
'success' => false,
'message' => 'Impossibile connettersi al server WHOIS: ' . $e->getMessage(),
'data' => null,
];
} catch (ServerMismatchException $e) {
// Il server non corrisponde al TLD richiesto
return [
'success' => false,
'message' => 'Server WHOIS non compatibile con il TLD: ' . $e->getMessage(),
'data' => null,
];
} catch (WhoisException $e) {
// Errore generico del protocollo WHOIS
return [
'success' => false,
'message' => 'Errore WHOIS: ' . $e->getMessage(),
'data' => null,
];
}
}
/**
* Verifica se un dominio risulta disponibile per la registrazione.
*
* @param string $domain
* @return bool
*/
public function isAvailable(string $domain): bool
{
try {
// Il metodo isDomainAvailable restituisce true se il dominio è libero
return $this->client->isDomainAvailable($domain);
} catch (WhoisException $e) {
return false;
}
}
}
Il servizio espone due metodi pubblici: lookup, che recupera le informazioni complete sul dominio e le restituisce come array normalizzato, e isAvailable, che effettua una verifica rapida sulla disponibilità del dominio per la registrazione.
Registrazione del servizio nel container
Per rendere il servizio risolvibile tramite dependency injection, lo registriamo nel AppServiceProvider:
<?php
namespace App\Providers;
use App\Services\WhoisService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Registriamo WhoisService come singleton nel container
$this->app->singleton(WhoisService::class, function ($app) {
return new WhoisService();
});
}
public function boot(): void
{
//
}
}
Registrarlo come singleton garantisce che durante il ciclo di vita di una singola richiesta HTTP venga creata una sola istanza del client WHOIS, evitando overhead non necessario.
Creazione del controller
Generiamo il controller tramite Artisan:
php artisan make:controller WhoisController
Implementiamo quindi il controller con i metodi necessari:
<?php
namespace App\Http\Controllers;
use App\Services\WhoisService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WhoisController extends Controller
{
// Iniettato dal container tramite costruttore
protected WhoisService $whoisService;
public function __construct(WhoisService $whoisService)
{
$this->whoisService = $whoisService;
}
/**
* Restituisce le informazioni WHOIS per il dominio specificato.
*
* @param Request $request
* @return JsonResponse
*/
public function lookup(Request $request): JsonResponse
{
// Validiamo il parametro domain
$validated = $request->validate([
'domain' => ['required', 'string', 'regex:/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
]);
$result = $this->whoisService->lookup($validated['domain']);
// Scegliamo il codice HTTP in base all'esito della ricerca
$statusCode = $result['success'] ? 200 : 422;
return response()->json($result, $statusCode);
}
/**
* Verifica la disponibilità di un dominio.
*
* @param Request $request
* @return JsonResponse
*/
public function availability(Request $request): JsonResponse
{
$validated = $request->validate([
'domain' => ['required', 'string', 'regex:/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/'],
]);
$available = $this->whoisService->isAvailable($validated['domain']);
return response()->json([
'domain' => $validated['domain'],
'available' => $available,
]);
}
}
La validazione usa un'espressione regolare che copre i nomi di dominio conformi agli standard IETF, inclusi i domini internazionalizzati nei loro form ASCII-compatibili (Punycode).
Definizione delle route
Aggiungiamo le route nel file routes/api.php:
<?php
use App\Http\Controllers\WhoisController;
use Illuminate\Support\Facades\Route;
// Raggruppiamo le route WHOIS sotto un prefisso dedicato
Route::prefix('whois')->group(function () {
// Ricerca informazioni per un dominio
Route::get('/lookup', [WhoisController::class, 'lookup']);
// Verifica disponibilità di un dominio
Route::get('/availability', [WhoisController::class, 'availability']);
});
Le route sono accessibili rispettivamente a /api/whois/lookup?domain=example.com e /api/whois/availability?domain=example.com.
Implementazione manuale tramite socket PHP
In alcuni scenari può essere preferibile non dipendere da librerie esterne e implementare direttamente il protocollo WHOIS tramite i socket di PHP. Il protocollo è sufficientemente semplice da rendere questa operazione fattibile in poche righe di codice.
Creiamo un secondo servizio, RawWhoisService, che gestisce la connessione TCP in modo diretto:
<?php
namespace App\Services;
use RuntimeException;
class RawWhoisService
{
// Porta standard del protocollo WHOIS
protected const WHOIS_PORT = 43;
// Timeout in secondi per la connessione e la lettura
protected const TIMEOUT = 10;
// Mappa TLD -> server WHOIS
protected array $serverMap = [
'com' => 'whois.verisign-grs.com',
'net' => 'whois.verisign-grs.com',
'org' => 'whois.pir.org',
'info' => 'whois.afilias.net',
'io' => 'whois.nic.io',
'it' => 'whois.nic.it',
'de' => 'whois.denic.de',
'uk' => 'whois.nic.uk',
'fr' => 'whois.afnic.fr',
'eu' => 'whois.eu',
];
/**
* Risolve il server WHOIS appropriato per il TLD del dominio.
*
* @param string $domain
* @return string
*/
protected function resolveServer(string $domain): string
{
// Estraiamo il TLD dall'ultimo segmento del dominio
$parts = explode('.', $domain);
$tld = strtolower(end($parts));
// Se il TLD è nella mappa usiamo il server noto, altrimenti IANA
return $this->serverMap[$tld] ?? 'whois.iana.org';
}
/**
* Esegue la query WHOIS via socket TCP e restituisce la risposta grezza.
*
* @param string $domain
* @param string|null $server
* @return string
* @throws RuntimeException
*/
public function query(string $domain, ?string $server = null): string
{
$host = $server ?? $this->resolveServer($domain);
// Apriamo la connessione TCP al server WHOIS
$socket = @fsockopen($host, self::WHOIS_PORT, $errorCode, $errorMessage, self::TIMEOUT);
if ($socket === false) {
throw new RuntimeException(
"Impossibile connettersi a {$host}:{$errorCode} - {$errorMessage}"
);
}
// Impostiamo il timeout di lettura
stream_set_timeout($socket, self::TIMEOUT);
// Inviamo la query: il dominio seguito da CRLF come da RFC 3912
fwrite($socket, $domain . "\r\n");
// Leggiamo la risposta fino alla chiusura della connessione
$response = '';
while (!feof($socket)) {
$chunk = fread($socket, 4096);
if ($chunk === false) {
break;
}
$response .= $chunk;
// Verifichiamo se il timeout è stato raggiunto
$meta = stream_get_meta_data($socket);
if ($meta['timed_out']) {
fclose($socket);
throw new RuntimeException('Timeout durante la lettura della risposta WHOIS.');
}
}
fclose($socket);
return $response;
}
/**
* Analizza la risposta grezza e ne estrae i campi principali.
*
* @param string $rawResponse
* @return array
*/
public function parse(string $rawResponse): array
{
$lines = explode("\n", $rawResponse);
$fields = [];
foreach ($lines as $line) {
// Saltiamo le righe di commento e quelle vuote
if (str_starts_with(trim($line), '%') || trim($line) === '') {
continue;
}
// Ogni campo è nel formato "Chiave: Valore"
if (str_contains($line, ':')) {
[$key, $value] = explode(':', $line, 2);
$normalizedKey = strtolower(trim(str_replace(' ', '_', $key)));
$trimmedValue = trim($value);
// Se la chiave esiste già, la trasformiamo in un array
if (isset($fields[$normalizedKey])) {
if (!is_array($fields[$normalizedKey])) {
$fields[$normalizedKey] = [$fields[$normalizedKey]];
}
$fields[$normalizedKey][] = $trimmedValue;
} else {
$fields[$normalizedKey] = $trimmedValue;
}
}
}
return $fields;
}
/**
* Metodo di convenienza: esegue la query e ne parsifica la risposta.
*
* @param string $domain
* @return array
*/
public function lookupRaw(string $domain): array
{
$raw = $this->query($domain);
return [
'raw' => $raw,
'parsed' => $this->parse($raw),
];
}
}
Questo servizio implementa l'intero ciclo WHOIS: risoluzione del server, apertura della connessione TCP, invio della query, lettura della risposta e parsing dei campi chiave-valore. Il parser è volutamente semplice e tollerante: gestisce chiavi duplicate trasformandole in array, salta le righe di commento (che nei server WHOIS iniziano con il carattere %), e normalizza le chiavi in formato snake_case.
Caching delle risposte
Le risposte WHOIS cambiano raramente nel corso di ore o giorni. Ha quindi senso introdurre un livello di cache per ridurre il numero di connessioni ai server WHOIS, molti dei quali applicano rate limiting. Laravel offre un'astrazione unificata per qualsiasi backend di cache tramite la facade Cache.
Modifichiamo WhoisService per aggiungere il caching:
<?php
namespace App\Services;
use Iodev\Whois\Factory;
use Iodev\Whois\Exceptions\WhoisException;
use Illuminate\Support\Facades\Cache;
class WhoisService
{
protected $client;
// Durata della cache in secondi (6 ore)
protected int $cacheTtl = 21600;
public function __construct()
{
$this->client = Factory::get()->createWhois();
}
public function lookup(string $domain): array
{
// Costruiamo una chiave di cache univoca per il dominio
$cacheKey = 'whois_lookup_' . md5(strtolower($domain));
// Restituiamo il risultato dalla cache se disponibile
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($domain) {
try {
$info = $this->client->loadDomainInfo($domain);
if ($info === null) {
return [
'success' => false,
'message' => 'Nessuna informazione trovata per il dominio specificato.',
'data' => null,
];
}
return [
'success' => true,
'message' => 'OK',
'data' => [
'domain' => $info->getDomainName(),
'registrar' => $info->getRegistrar(),
'created_at' => $info->getCreationDate()
? date('Y-m-d H:i:s', $info->getCreationDate())
: null,
'updated_at' => $info->getUpdatedDate()
? date('Y-m-d H:i:s', $info->getUpdatedDate())
: null,
'expires_at' => $info->getExpirationDate()
? date('Y-m-d H:i:s', $info->getExpirationDate())
: null,
'nameservers' => $info->getNameServers(),
'states' => $info->getStates(),
'owner' => $info->getOwner(),
],
];
} catch (WhoisException $e) {
return [
'success' => false,
'message' => 'Errore WHOIS: ' . $e->getMessage(),
'data' => null,
];
}
});
}
public function isAvailable(string $domain): bool
{
$cacheKey = 'whois_available_' . md5(strtolower($domain));
// Cache breve per la disponibilità (30 minuti)
return Cache::remember($cacheKey, 1800, function () use ($domain) {
try {
return $this->client->isDomainAvailable($domain);
} catch (WhoisException $e) {
return false;
}
});
}
}
La chiave di cache include un hash MD5 del dominio per evitare problemi con caratteri speciali e per garantire lunghezze uniformi. Il TTL per la disponibilità è più breve (30 minuti) poiché questa informazione è più soggetta a variazione rispetto ai dati di registrazione.
Gestione tramite job in coda
In scenari dove la risposta WHOIS non è necessaria in tempo reale, ad esempio per arricchire un database di domini monitorati, conviene spostare la chiamata in un job asincrono. Generiamo il job:
php artisan make:job FetchWhoisInfoJob
Implementiamo il job:
<?php
namespace App\Jobs;
use App\Models\Domain;
use App\Services\WhoisService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class FetchWhoisInfoJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// Numero massimo di tentativi in caso di errore
public int $tries = 3;
// Backoff esponenziale tra i tentativi (in secondi)
public array $backoff = [30, 120, 600];
protected string $domainName;
public function __construct(string $domainName)
{
// Normalizziamo il dominio prima di salvarlo
$this->domainName = strtolower(trim($domainName));
}
public function handle(WhoisService $whoisService): void
{
$result = $whoisService->lookup($this->domainName);
if (!$result['success']) {
Log::warning('Ricerca WHOIS fallita', [
'domain' => $this->domainName,
'message' => $result['message'],
]);
return;
}
// Aggiorniamo o creiamo il record nel database
Domain::updateOrCreate(
['name' => $this->domainName],
[
'registrar' => $result['data']['registrar'],
'created_at' => $result['data']['created_at'],
'expires_at' => $result['data']['expires_at'],
'nameservers' => json_encode($result['data']['nameservers']),
'whois_fetched_at' => now(),
]
);
}
public function failed(\Throwable $exception): void
{
// Registriamo il fallimento definitivo del job
Log::error('FetchWhoisInfoJob fallito definitivamente', [
'domain' => $this->domainName,
'exception' => $exception->getMessage(),
]);
}
}
Il job implementa ShouldQueue e definisce un backoff esponenziale tra i tentativi: questo è importante perché molti server WHOIS limitano il numero di richieste al minuto, e un backoff progressivo riduce il rischio di essere bloccati.
Per accodare il job da un controller o da un comando Artisan è sufficiente:
// Accodiamo il job passando il nome del dominio
FetchWhoisInfoJob::dispatch('example.com');
// Oppure con un ritardo di 5 minuti
FetchWhoisInfoJob::dispatch('example.com')->delay(now()->addMinutes(5));
Comando Artisan per interrogazioni da CLI
Può essere utile disporre di un comando Artisan che permette di interrogare il WHOIS direttamente dalla riga di comando, sia per test che per utilizzi in script di sistema:
php artisan make:command WhoisLookupCommand
<?php
namespace App\Console\Commands;
use App\Services\WhoisService;
use Illuminate\Console\Command;
class WhoisLookupCommand extends Command
{
// Firma del comando con argomento obbligatorio
protected $signature = 'whois:lookup {domain : Il nome di dominio da interrogare}';
protected $description = 'Effettua una ricerca WHOIS per il dominio specificato';
public function handle(WhoisService $whoisService): int
{
$domain = $this->argument('domain');
$this->info("Ricerca WHOIS per: {$domain}");
$this->newLine();
$result = $whoisService->lookup($domain);
if (!$result['success']) {
$this->error($result['message']);
return Command::FAILURE;
}
$data = $result['data'];
// Mostriamo i dati in formato tabellare
$this->table(
['Campo', 'Valore'],
[
['Dominio', $data['domain'] ?? '-'],
['Registrar', $data['registrar'] ?? '-'],
['Creato il', $data['created_at'] ?? '-'],
['Aggiornato', $data['updated_at'] ?? '-'],
['Scade il', $data['expires_at'] ?? '-'],
['Proprietario', $data['owner'] ?? '-'],
['Nameserver', implode(', ', (array) ($data['nameservers'] ?? []))],
['Stati', implode(', ', (array) ($data['states'] ?? []))],
]
);
return Command::SUCCESS;
}
}
Il comando è eseguibile con:
php artisan whois:lookup example.com
Test unitari e di integrazione
Un servizio che dipende da connessioni di rete esterne va testato con attenzione. Per i test unitari utilizzeremo i mock di PHPUnit per isolare il servizio dalla rete, mentre per i test di integrazione possiamo prevedere un flag che abilita le chiamate reali solo in ambienti appropriati.
php artisan make:test WhoisServiceTest --unit
<?php
namespace Tests\Unit;
use App\Services\WhoisService;
use Iodev\Whois\Factory;
use Iodev\Whois\Whois;
use Iodev\Whois\Modules\Tld\TldInfo;
use PHPUnit\Framework\MockObject\MockObject;
use Tests\TestCase;
class WhoisServiceTest extends TestCase
{
// Mock del client WHOIS sottostante
protected MockObject $whoisMock;
protected WhoisService $service;
protected function setUp(): void
{
parent::setUp();
// Creiamo il mock della classe Whois
$this->whoisMock = $this->createMock(Whois::class);
// Sostituiamo l'istanza nel servizio tramite reflection
$this->service = new WhoisService();
$reflection = new \ReflectionClass($this->service);
$property = $reflection->getProperty('client');
$property->setAccessible(true);
$property->setValue($this->service, $this->whoisMock);
}
public function test_lookup_returns_structured_data_on_success(): void
{
// Creiamo un mock di TldInfo con dati di esempio
$infoMock = $this->createMock(TldInfo::class);
$infoMock->method('getDomainName')->willReturn('example.com');
$infoMock->method('getRegistrar')->willReturn('Example Registrar, Inc.');
$infoMock->method('getCreationDate')->willReturn(strtotime('2000-01-01'));
$infoMock->method('getExpirationDate')->willReturn(strtotime('2030-01-01'));
$infoMock->method('getUpdatedDate')->willReturn(strtotime('2023-06-15'));
$infoMock->method('getNameServers')->willReturn(['ns1.example.com', 'ns2.example.com']);
$infoMock->method('getStates')->willReturn(['clientTransferProhibited']);
$infoMock->method('getOwner')->willReturn('Example Owner');
// Il client mock restituisce l'info mock
$this->whoisMock
->expects($this->once())
->method('loadDomainInfo')
->with('example.com')
->willReturn($infoMock);
$result = $this->service->lookup('example.com');
// Verifichiamo la struttura e i valori attesi
$this->assertTrue($result['success']);
$this->assertEquals('example.com', $result['data']['domain']);
$this->assertEquals('Example Registrar, Inc.', $result['data']['registrar']);
$this->assertIsArray($result['data']['nameservers']);
}
public function test_lookup_returns_error_when_info_is_null(): void
{
// Il server WHOIS non restituisce dati per questo dominio
$this->whoisMock
->method('loadDomainInfo')
->willReturn(null);
$result = $this->service->lookup('nonexistent.example');
$this->assertFalse($result['success']);
$this->assertNull($result['data']);
}
}
Considerazioni su rate limiting e conformità
Prima di mettere in produzione un'applicazione che effettua richieste WHOIS, è importante tenere presenti alcune limitazioni pratiche e normative.
Dal punto di vista tecnico, la maggior parte dei server WHOIS applica rate limiting per IP. I limiti variano da registro a registro: alcuni consentono centinaia di query all'ora, altri si limitano a poche decine. Superare questi limiti può portare al blocco temporaneo o permanente dell'IP. L'introduzione di un layer di cache, come illustrato in precedenza, è la misura più efficace per contenere il volume di richieste.
Dal punto di vista normativo, il GDPR ha profondamente modificato i dati disponibili tramite WHOIS per i domini di persone fisiche residenti nell'UE. Molti campi relativi ai dati del registrante (nome, email, telefono, indirizzo) sono ormai sistematicamente oscurati o sostituiti con servizi proxy. Questo va tenuto presente quando si progettano funzionalità che dipendono da queste informazioni.
Infine, l'utilizzo dei dati WHOIS a fini di spam, phishing o raccolta massiva non autorizzata è proibito dai termini di servizio di praticamente tutti i registry. Le applicazioni che aggregano dati WHOIS devono garantire che il loro utilizzo sia conforme alle politiche di accettabile utilizzo (AUP) del registro di riferimento.
Conclusioni
In questo articolo abbiamo costruito un'integrazione WHOIS completa per Laravel, partendo dalla libreria io-developer/php-whois come soluzione di alto livello e affiancando un'implementazione socket-based per scenari che richiedono maggiore controllo. Abbiamo strutturato il codice seguendo le convenzioni del framework: service class registrata nel container, controller con validazione, route API, caching delle risposte, job per elaborazione asincrona, comando Artisan e test unitari con mock. Questa architettura è scalabile e facilmente estendibile, ad esempio per supportare interrogazioni su indirizzi IP tramite server WHOIS ARIN, RIPE NCC o APNIC, oppure per integrare un sistema di alerting basato sulle date di scadenza dei domini monitorati.