Gestire le query WHOIS in Laravel
Un client WHOIS in PHP puro funziona bene in uno script, ma un'applicazione web pone problemi diversi: le richieste arrivano da processi separati che non condividono memoria, la cache deve essere comune a tutti i worker, i limiti di frequenza verso i server WHOIS vanno rispettati globalmente e non per singolo processo, e il codice deve essere testabile senza toccare la rete. Laravel offre una risposta pronta a ciascuno di questi problemi. In questo articolo costruiamo un modulo WHOIS completo per un'applicazione Laravel moderna: configurazione dedicata, un servizio registrato nel container, cache e lock distribuiti, rate limiter condiviso, un comando Artisan, un endpoint API con validazione, un comando pianificato per monitorare la scadenza dei domini e una suite di test.
Un'osservazione preliminare sul protocollo. Dal 28 gennaio 2025 ICANN non impone più a registry e registrar dei domini generici (gTLD) di offrire WHOIS sulla porta 43, e il protocollo di riferimento per quei domini è RDAP. WHOIS resta però in uso presso gran parte dei registry nazionali, compresi quelli di .it e .de, e presso alcuni registry generici, tra cui quello del .com. Il modulo che scriveremo deve quindi tollerare server che non rispondono più, in particolare quelli dei registrar.
Il protocollo in breve
La RFC 3912 definisce WHOIS in modo minimale: connessione TCP alla porta 43, invio della query terminata da \r\n, lettura fino alla chiusura della connessione. Non ci sono intestazioni, lunghezze o codifiche dichiarate, e la risposta è testo libero. Il server da interrogare si trova partendo da whois.iana.org, che nel campo refer: indica il server del registry del TLD; la risposta del registry può a sua volta indicare nel campo Registrar WHOIS Server: il server del registrar.
Da qui i requisiti già noti a chiunque abbia scritto un client WHOIS: timeout, limite alla dimensione della risposta, decodifica con ripiego da UTF-8 a Latin-1, validazione dell'input contro l'iniezione di righe, gestione dei referral e parsing tollerante. In Laravel si aggiungono cache condivisa, lock e rate limiting distribuiti.
Struttura del modulo
Organizziamo il codice in app/Services/Whois, separando le responsabilità:
config/whois.phpcontiene i parametri configurabili;Contracts/WhoisTransport.phpdefinisce l'interfaccia del trasporto di rete;SocketTransport.phpè l'implementazione basata sui socket;DomainName.phpnormalizza e valida i domini;WhoisParser.phpinterpreta le risposte;WhoisRecord.phpè l'oggetto che rappresenta il risultato;WhoisService.phpcoordina referral, cache, lock e rate limiting;Exceptions/contiene le eccezioni del modulo.
L'interfaccia del trasporto è il punto chiave per la testabilità: nei test la sostituiremo con un'implementazione che restituisce risposte prefabbricate, verificando l'intera logica senza alcuna connessione di rete.
La configurazione
Tutti i parametri vanno in un file di configurazione dedicato, con valori letti dalle variabili d'ambiente. È buona pratica che il codice applicativo legga solo da config() e mai direttamente da env(), perché quando la configurazione è in cache le chiamate a env() fuori dai file di configurazione restituiscono null.
<?php
// config/whois.php
return [
// Timeout di inattività e scadenza complessiva per ogni query, in secondi
'timeout' => (float) env('WHOIS_TIMEOUT', 10),
'deadline' => (float) env('WHOIS_DEADLINE', 30),
// Dimensione massima accettata per una risposta
'max_bytes' => (int) env('WHOIS_MAX_BYTES', 1024 * 1024),
// Durata della cache dei risultati e dei server per TLD, in secondi
'cache_ttl' => (int) env('WHOIS_CACHE_TTL', 3600),
'tld_cache_ttl' => (int) env('WHOIS_TLD_CACHE_TTL', 7 * 24 * 3600),
// Numero massimo di query al minuto verso ciascun server
'per_server_per_minute' => (int) env('WHOIS_PER_SERVER_PER_MINUTE', 30),
'iana_server' => 'whois.iana.org',
'max_hops' => 4,
// Domini da monitorare per la scadenza, separati da virgole
'monitored_domains' => array_values(array_filter(
array_map('trim', explode(',', (string) env('WHOIS_MONITORED_DOMAINS', ''))),
)),
// Modelli di query per i server che richiedono una sintassi particolare
'query_templates' => [
'whois.verisign-grs.com' => 'domain %s',
],
];
Il modello per whois.verisign-grs.com antepone la parola chiave domain al nome cercato: senza di essa il server di Verisign cerca la stringa anche tra i nomi dei name server registrati.
Eccezioni e oggetti di dominio
Definiamo una gerarchia di eccezioni: una base comune, una per gli input non validi, una per gli errori dei server remoti e una per il superamento del limite di frequenza. L'eccezione per i domini non validi implementa il metodo render(), che Laravel invoca automaticamente per trasformarla in una risposta HTTP quando non viene intercettata.
<?php
namespace App\Services\Whois\Exceptions;
use RuntimeException;
class WhoisException extends RuntimeException
{
}
<?php
namespace App\Services\Whois\Exceptions;
use Illuminate\Http\JsonResponse;
class InvalidDomainException extends WhoisException
{
public function __construct(public readonly string $input)
{
parent::__construct("Dominio non valido: {$input}");
}
public function render(): JsonResponse
{
return response()->json(['message' => $this->getMessage()], 422);
}
}
<?php
namespace App\Services\Whois\Exceptions;
use Throwable;
class WhoisServerException extends WhoisException
{
public function __construct(
public readonly string $server,
string $message,
?Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
namespace App\Services\Whois\Exceptions;
class WhoisRateLimitedException extends WhoisServerException
{
public function __construct(string $server, public readonly int $retryAfter)
{
parent::__construct($server, "Limite di frequenza raggiunto per {$server}");
}
}
L'oggetto DomainName normalizza l'input. La validazione impedisce l'iniezione di righe aggiuntive nella query, mentre idn_to_ascii() converte i domini internazionalizzati in Punycode: caffè.it diventa xn--caff-8oa.it. Serve l'estensione intl, presente in quasi tutte le installazioni di PHP usate con Laravel.
<?php
namespace App\Services\Whois;
use App\Services\Whois\Exceptions\InvalidDomainException;
use Illuminate\Support\Str;
final class DomainName
{
private function __construct(public readonly string $ascii)
{
}
public static function from(string $input): self
{
// Rimuove spazi, maiuscole e l'eventuale punto finale
$domain = rtrim(Str::lower(trim($input)), '.');
$ascii = idn_to_ascii(
$domain,
IDNA_NONTRANSITIONAL_TO_ASCII | IDNA_USE_STD3_RULES,
INTL_IDNA_VARIANT_UTS46,
);
if ($ascii === false || preg_match('/^[a-z0-9.-]+$/', $ascii) !== 1 || strlen($ascii) > 253) {
throw new InvalidDomainException($input);
}
$labels = explode('.', $ascii);
$invalid = count($labels) < 2 || collect($labels)->contains(
fn (string $label) => $label === ''
|| strlen($label) > 63
|| str_starts_with($label, '-')
|| str_ends_with($label, '-')
);
if ($invalid) {
throw new InvalidDomainException($input);
}
return new self($ascii);
}
public function tld(): string
{
return Str::afterLast($this->ascii, '.');
}
public function __toString(): string
{
return $this->ascii;
}
}
Il risultato è rappresentato da una classe di sola lettura che implementa Arrayable e JsonSerializable, così può essere restituita direttamente da un controller o memorizzata in cache.
<?php
namespace App\Services\Whois;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
final readonly class WhoisRecord implements Arrayable, JsonSerializable
{
/**
* @param list<string> $nameServers
* @param list<string> $status
* @param list<string> $servers
*/
public function __construct(
public string $domain,
public bool $available,
public ?string $registrar,
public ?string $createdAt,
public ?string $updatedAt,
public ?string $expiresAt,
public array $nameServers,
public array $status,
public array $servers,
public string $raw,
) {
}
public function toArray(bool $withRaw = false): array
{
$data = [
'domain' => $this->domain,
'available' => $this->available,
'registrar' => $this->registrar,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
'expires_at' => $this->expiresAt,
'name_servers' => $this->nameServers,
'status' => $this->status,
'servers' => $this->servers,
];
return $withRaw ? $data + ['raw' => $this->raw] : $data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
Il trasporto di rete
L'interfaccia è minimale: un solo metodo che riceve server e query e restituisce il testo della risposta.
<?php
namespace App\Services\Whois\Contracts;
interface WhoisTransport
{
public function query(string $server, string $query): string;
}
L'implementazione usa stream_socket_client() con timeout di connessione, stream_set_timeout() per il timeout di inattività e un controllo esplicito della scadenza complessiva. La decodifica restituisce la risposta così com'è se è UTF-8 valido e la converte da Latin-1 altrimenti.
<?php
namespace App\Services\Whois;
use App\Services\Whois\Contracts\WhoisTransport;
use App\Services\Whois\Exceptions\WhoisServerException;
final class SocketTransport implements WhoisTransport
{
public function __construct(
private readonly float $timeout,
private readonly float $deadline,
private readonly int $maxBytes,
) {
}
public function query(string $server, string $query): string
{
$socket = @stream_socket_client("tcp://{$server}:43", $errorCode, $errorMessage, $this->timeout);
if ($socket === false) {
throw new WhoisServerException($server, "Connessione fallita ({$errorCode}): {$errorMessage}");
}
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 WhoisServerException($server, 'Invio della query fallito');
}
$buffer = '';
$deadlineAt = microtime(true) + $this->deadline;
while (!feof($socket)) {
$chunk = fread($socket, 8192);
if (stream_get_meta_data($socket)['timed_out']) {
throw new WhoisServerException($server, 'Nessuna risposta entro il timeout');
}
if ($chunk === false) {
throw new WhoisServerException($server, 'Errore durante la lettura');
}
$buffer .= $chunk;
if (strlen($buffer) > $this->maxBytes) {
throw new WhoisServerException($server, 'Risposta oltre il limite consentito');
}
if (microtime(true) > $deadlineAt) {
throw new WhoisServerException($server, 'Scadenza complessiva superata');
}
}
// UTF-8 se valido, altrimenti conversione da Latin-1
return mb_check_encoding($buffer, 'UTF-8')
? $buffer
: mb_convert_encoding($buffer, 'UTF-8', 'ISO-8859-1');
} finally {
fclose($socket);
}
}
}
Il parser
Il parser riconosce le due famiglie di formati più comuni: righe Chiave: valore e blocchi con un'intestazione senza due punti seguita da righe rientrate, come nelle risposte del registry italiano. Le collection di Laravel rendono compatte le funzioni di estrazione.
<?php
namespace App\Services\Whois;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class WhoisParser
{
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_PATTERN =
'/^(no match for|not found|status:\s*(available|free)\b)|no data found|no entries found|domain not found/im';
/**
* @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 (Str::startsWith($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 = Str::lower($line);
}
continue;
}
$key = Str::lower(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;
}
public function isAvailable(string $text): bool
{
return preg_match(self::NOT_FOUND_PATTERN, $text) === 1;
}
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 = Str::lower(preg_replace(['#^[a-z]+://#i', '#[/:].*$#'], '', $matches[1]));
if (preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $host) === 1) {
return $host;
}
}
}
return null;
}
/**
* @param list<string> $servers
*/
public function record(DomainName $domain, string $text, array $servers): WhoisRecord
{
$fields = collect($this->fields($text));
$values = fn (string $name): Collection => collect(self::FIELD_ALIASES[$name])
->flatMap(fn (string $alias) => $fields->get($alias, []));
$first = fn (string $name): ?string => $values($name)->first();
// Teniamo solo il primo token: alcuni server aggiungono IP o URL
$tokens = fn (string $name): array => $values($name)
->map(fn (string $value) => Str::lower(Str::before($value, ' ')))
->unique()
->values()
->all();
return new WhoisRecord(
domain: $domain->ascii,
available: $this->isAvailable($text),
registrar: $first('registrar'),
createdAt: $first('created_at'),
updatedAt: $first('updated_at'),
expiresAt: $first('expires_at'),
nameServers: $tokens('name_servers'),
status: $tokens('status'),
servers: $servers,
raw: $text,
);
}
}
Il rilevamento della disponibilità è un'euristica sul testo grezzo: la conferma definitiva arriva solo dal registrar al momento della registrazione. Le date restano stringhe, perché ogni registry ha il proprio formato; la conversione in oggetti Carbon la faremo solo dove serve, nel comando di monitoraggio, gestendo esplicitamente i valori non interpretabili.
Il servizio: cache, lock e rate limiting distribuiti
Il servizio è il cuore del modulo e il punto in cui Laravel fa la differenza rispetto a un client in PHP puro. Tre meccanismi lavorano insieme:
- la cache memorizza i risultati e l'associazione tra TLD e server del registry, condividendoli tra tutti i worker;
- un lock atomico per dominio garantisce che, se più richieste chiedono lo stesso dominio nello stesso momento, solo una esegua la ricerca mentre le altre attendono e trovano poi il risultato in cache;
- il rate limiter conta le query verso ciascun server WHOIS in un archivio comune, così il limite vale per l'intera applicazione e non per il singolo processo.
Lock e rate limiter richiedono un driver di cache che supporti operazioni atomiche, come Redis, Memcached o il database. Il driver file supporta i lock su una sola macchina, mentre il driver array è adatto solo ai test.
<?php
namespace App\Services\Whois;
use App\Services\Whois\Contracts\WhoisTransport;
use App\Services\Whois\Exceptions\WhoisRateLimitedException;
use App\Services\Whois\Exceptions\WhoisServerException;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
final class WhoisService
{
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly WhoisTransport $transport,
private readonly WhoisParser $parser,
private readonly array $config,
) {
}
public function lookup(string $input, bool $fresh = false): WhoisRecord
{
$domain = DomainName::from($input);
$cacheKey = "whois:record:{$domain}";
if (!$fresh && ($cached = Cache::get($cacheKey)) instanceof WhoisRecord) {
return $cached;
}
try {
// Solo una richiesta alla volta esegue la ricerca per questo dominio
return Cache::lock("whois:lock:{$domain}", (int) $this->config['deadline'] * 2)
->block((int) $this->config['deadline'], function () use ($domain, $cacheKey, $fresh) {
// Chi ha atteso il lock trova spesso il risultato già pronto
if (!$fresh && ($cached = Cache::get($cacheKey)) instanceof WhoisRecord) {
return $cached;
}
$record = $this->resolve($domain);
Cache::put($cacheKey, $record, $this->config['cache_ttl']);
return $record;
});
} catch (LockTimeoutException $e) {
throw new WhoisServerException('lock', "Ricerca di {$domain} già in corso, riprovare", $e);
}
}
private function resolve(DomainName $domain): WhoisRecord
{
$iana = $this->config['iana_server'];
$server = $this->registryServer($domain);
$servers = [];
$answer = null;
$visited = [];
while ($server !== null && count($servers) < $this->config['max_hops'] && !isset($visited[$server])) {
$visited[$server] = true;
$servers[] = $server;
try {
$text = $this->query($server, $domain);
} catch (WhoisServerException $e) {
// Con una risposta del registry già ottenuta, un registrar muto non è fatale
if ($answer !== null) {
Log::info('Server WHOIS del registrar non raggiungibile', [
'domain' => $domain->ascii,
'server' => $server,
'error' => $e->getMessage(),
]);
break;
}
throw $e;
}
$answer = $text;
$server = $this->parser->referral($text);
}
if ($answer === null) {
throw new WhoisServerException($iana, "Nessun server WHOIS trovato per {$domain}");
}
return $this->parser->record($domain, $answer, $servers);
}
private function registryServer(DomainName $domain): ?string
{
$tld = $domain->tld();
// Il server del registry per un TLD cambia raramente: lo si memorizza a lungo
$server = Cache::remember(
"whois:tld:{$tld}",
$this->config['tld_cache_ttl'],
fn () => $this->parser->referral($this->query($this->config['iana_server'], $domain)) ?? '',
);
return $server === '' ? null : $server;
}
private function query(string $server, DomainName $domain): string
{
$template = $this->config['query_templates'][$server] ?? '%s';
$key = "whois:server:{$server}";
// Il limite vale per tutta l'applicazione perché il contatore vive nella cache condivisa
if (RateLimiter::tooManyAttempts($key, $this->config['per_server_per_minute'])) {
throw new WhoisRateLimitedException($server, RateLimiter::availableIn($key));
}
RateLimiter::hit($key, 60);
return $this->transport->query($server, sprintf($template, $domain->ascii));
}
}
Alcuni dettagli meritano attenzione. Il lock ha una durata pari al doppio della scadenza complessiva: se il processo che lo detiene si blocca o termina in modo anomalo, il lock scade da solo e non resta acquisito per sempre. Il metodo block() attende fino al numero di secondi indicato e, se il lock non si libera, solleva LockTimeoutException, che trasformiamo in un errore del modulo.
A differenza delle versioni in altri linguaggi, la risposta di IANA non entra nella catena dei server: registryServer() la usa solo per ricavare il server del registry e memorizzarlo. Quando IANA non indica alcun server, salviamo una stringa vuota invece di null, perché Cache::remember() non memorizza i valori nulli e ripeterebbe l'interrogazione a ogni ricerca.
Il rate limiter non attende: se il limite è raggiunto solleva subito un'eccezione con il numero di secondi dopo i quali riprovare. In un contesto web è la scelta corretta, perché tenere occupato un worker in attesa danneggerebbe tutte le altre richieste; sarà il chiamante, ad esempio il controller con un codice 429 o un job in coda con un rilascio ritardato, a decidere come reagire.
Infine, l'oggetto WhoisRecord viene memorizzato in cache tramite serializzazione PHP. Dalla versione 11 Laravel consente di limitare le classi deserializzabili dalla cache tramite l'opzione serializable_classes in config/cache.php; se la si usa, va aggiunta WhoisRecord all'elenco.
Registrazione nel container
Colleghiamo interfaccia e implementazione nel metodo register() di AppServiceProvider, passando i parametri dalla configurazione. Registrare il servizio come singleton evita di ricostruirlo a ogni risoluzione.
<?php
namespace App\Providers;
use App\Services\Whois\Contracts\WhoisTransport;
use App\Services\Whois\SocketTransport;
use App\Services\Whois\WhoisParser;
use App\Services\Whois\WhoisService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(WhoisTransport::class, fn () => new SocketTransport(
timeout: config('whois.timeout'),
deadline: config('whois.deadline'),
maxBytes: config('whois.max_bytes'),
));
$this->app->singleton(WhoisService::class, fn ($app) => new WhoisService(
transport: $app->make(WhoisTransport::class),
parser: new WhoisParser(),
config: config('whois'),
));
}
}
Un comando Artisan
Un comando Artisan è comodo per le verifiche manuali e per la diagnosi. Dalla versione 11 di Laravel i comandi in app/Console/Commands vengono registrati automaticamente.
<?php
namespace App\Console\Commands;
use App\Services\Whois\Exceptions\WhoisException;
use App\Services\Whois\WhoisService;
use Illuminate\Console\Command;
class WhoisLookupCommand extends Command
{
protected $signature = 'whois:lookup
{domain : Il dominio da interrogare}
{--fresh : Ignora la cache}
{--raw : Mostra il testo originale della risposta}
{--json : Restituisce il risultato in formato JSON}';
protected $description = 'Esegue una query WHOIS seguendo i referral';
public function handle(WhoisService $whois): int
{
try {
$record = $whois->lookup($this->argument('domain'), (bool) $this->option('fresh'));
} catch (WhoisException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
if ($this->option('json')) {
$this->line(json_encode(
$record->toArray(withRaw: (bool) $this->option('raw')),
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
));
return self::SUCCESS;
}
$this->table(['Campo', 'Valore'], [
['Dominio', $record->domain],
['Disponibile', $record->available ? 'sì' : 'no'],
['Registrar', $record->registrar ?? '-'],
['Creato', $record->createdAt ?? '-'],
['Scadenza', $record->expiresAt ?? '-'],
['Name server', implode(', ', $record->nameServers) ?: '-'],
['Stato', implode(', ', $record->status) ?: '-'],
['Server interrogati', implode(' -> ', $record->servers)],
]);
if ($this->option('raw')) {
$this->newLine();
$this->line($record->raw);
}
return self::SUCCESS;
}
}
php artisan whois:lookup example.com
php artisan whois:lookup caffè.it --fresh --json
L'endpoint API
Per esporre il servizio via HTTP serve un controller, con le rotte definite in routes/api.php; nelle applicazioni create con Laravel 11 o successivo il file si genera con php artisan install:api. La validazione del formato avviene già nella rotta tramite un vincolo sul parametro, mentre il middleware throttle limita le richieste dei client verso la nostra API, una protezione distinta dal limite che applichiamo verso i server WHOIS.
<?php
// routes/api.php
use App\Http\Controllers\WhoisController;
use Illuminate\Support\Facades\Route;
Route::get('/whois/{domain}', WhoisController::class)
->where('domain', '[^/]{3,255}')
->middleware('throttle:30,1')
->name('whois.lookup');
<?php
namespace App\Http\Controllers;
use App\Services\Whois\Exceptions\WhoisRateLimitedException;
use App\Services\Whois\Exceptions\WhoisServerException;
use App\Services\Whois\WhoisService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WhoisController extends Controller
{
public function __invoke(Request $request, string $domain, WhoisService $whois): JsonResponse
{
$validated = $request->validate([
'fresh' => ['sometimes', 'boolean'],
'raw' => ['sometimes', 'boolean'],
]);
try {
$record = $whois->lookup($domain, (bool) ($validated['fresh'] ?? false));
} catch (WhoisRateLimitedException $e) {
return response()
->json(['message' => $e->getMessage(), 'server' => $e->server], 429)
->header('Retry-After', (string) $e->retryAfter);
} catch (WhoisServerException $e) {
return response()->json(['message' => $e->getMessage(), 'server' => $e->server], 502);
}
return response()->json($record->toArray(withRaw: (bool) ($validated['raw'] ?? false)));
}
}
L'eccezione InvalidDomainException non viene intercettata nel controller: il suo metodo render() la trasforma automaticamente in una risposta 422. Il limite di frequenza verso i server WHOIS produce invece una risposta 429 con l'intestazione Retry-After, che indica ai client quando riprovare, mentre gli altri errori remoti diventano un 502.
Monitorare la scadenza dei domini
Un uso pratico di tutto questo è il monitoraggio della scadenza dei propri domini. Un comando legge l'elenco dalla configurazione, interroga il servizio, interpreta la data di scadenza con Carbon e segnala i domini in scadenza. Per non sovraccaricare i server WHOIS le ricerche procedono in sequenza con una breve pausa, e il risultato viene preso dalla cache quando disponibile.
<?php
namespace App\Console\Commands;
use App\Services\Whois\Exceptions\WhoisException;
use App\Services\Whois\WhoisService;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
class WhoisExpiryCheckCommand extends Command
{
protected $signature = 'whois:check-expiry {--days=30 : Soglia di preavviso in giorni}';
protected $description = 'Segnala i domini monitorati prossimi alla scadenza';
public function handle(WhoisService $whois): int
{
$threshold = CarbonImmutable::now()->addDays((int) $this->option('days'));
$domains = config('whois.monitored_domains', []);
$failures = 0;
foreach ($domains as $domain) {
try {
$record = $whois->lookup($domain);
} catch (WhoisException $e) {
$failures++;
Log::warning('Controllo WHOIS fallito', ['domain' => $domain, 'error' => $e->getMessage()]);
continue;
}
$expiresAt = $this->parseDate($record->expiresAt);
if ($expiresAt === null) {
$this->warn("{$domain}: data di scadenza non disponibile");
continue;
}
if ($expiresAt->lessThanOrEqualTo($threshold)) {
$this->error("{$domain}: scade il {$expiresAt->toDateString()}");
Log::alert('Dominio in scadenza', ['domain' => $domain, 'expires_at' => $expiresAt->toIso8601String()]);
} else {
$this->info("{$domain}: valido fino al {$expiresAt->toDateString()}");
}
// Pausa tra una ricerca e l'altra per rispettare i server
usleep(500_000);
}
return $failures > 0 ? self::FAILURE : self::SUCCESS;
}
private function parseDate(?string $value): ?CarbonImmutable
{
if ($value === null) {
return null;
}
try {
// Carbon riconosce i formati ISO 8601 e la maggior parte delle date leggibili
return CarbonImmutable::parse($value)->utc();
} catch (Throwable) {
return null;
}
}
}
L'elenco dei domini arriva dalla chiave monitored_domains della configurazione, alimentata dalla variabile d'ambiente WHOIS_MONITORED_DOMAINS, e la pianificazione si dichiara in routes/console.php. L'opzione withoutOverlapping() impedisce che due esecuzioni si sovrappongano se una dovesse durare più del previsto.
<?php
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('whois:check-expiry --days=30')
->dailyAt('07:15')
->withoutOverlapping()
->onOneServer();
Testare senza rete
L'interfaccia WhoisTransport permette di sostituire la rete con un'implementazione finta registrata nel container. I test seguenti, scritti con PHPUnit, verificano la gestione dei referral, la tolleranza verso un registrar che non risponde e la risposta dell'endpoint a un input non valido. Nell'ambiente di test Laravel usa il driver di cache array, che supporta lock e rate limiter all'interno dello stesso processo.
<?php
namespace Tests\Feature;
use App\Services\Whois\Contracts\WhoisTransport;
use App\Services\Whois\Exceptions\WhoisServerException;
use Tests\TestCase;
class WhoisTest extends TestCase
{
private array $calls = [];
private function fakeTransport(array $responses): void
{
$test = $this;
$this->app->instance(WhoisTransport::class, new class ($responses, $test) implements WhoisTransport {
public function __construct(private array $responses, private WhoisTest $test)
{
}
public function query(string $server, string $query): string
{
$this->test->recordCall($server);
// Un valore nullo simula un server che non risponde
return $this->responses[$server]
?? throw new WhoisServerException($server, 'Timeout simulato');
}
});
// Il servizio è un singleton: lo si rimuove perché venga ricostruito con il trasporto finto
$this->app->forgetInstance(\App\Services\Whois\WhoisService::class);
}
public function recordCall(string $server): void
{
$this->calls[] = $server;
}
public function test_it_follows_referrals_and_tolerates_a_silent_registrar(): void
{
$this->fakeTransport([
'whois.iana.org' => "refer: whois.verisign-grs.com\n",
'whois.verisign-grs.com' => "Domain Name: EXAMPLE.COM\n"
. "Registrar WHOIS Server: whois.registrar.test\n"
. "Registrar: Example Inc.\n"
. "Registry Expiry Date: 2030-01-01T00:00:00Z\n",
'whois.registrar.test' => null,
]);
$this->getJson('/api/whois/example.com')
->assertOk()
->assertJson([
'domain' => 'example.com',
'available' => false,
'registrar' => 'Example Inc.',
'expires_at' => '2030-01-01T00:00:00Z',
'servers' => ['whois.verisign-grs.com', 'whois.registrar.test'],
]);
$this->assertSame(['whois.iana.org', 'whois.verisign-grs.com', 'whois.registrar.test'], $this->calls);
}
public function test_results_are_cached(): void
{
$this->fakeTransport([
'whois.iana.org' => "refer: whois.nic.it\n",
'whois.nic.it' => "Domain: example.it\nStatus: ok\n",
]);
$this->getJson('/api/whois/example.it')->assertOk();
$this->getJson('/api/whois/example.it')->assertOk();
// La seconda richiesta non tocca la rete
$this->assertCount(2, $this->calls);
}
public function test_invalid_domains_are_rejected(): void
{
$this->fakeTransport([]);
$this->getJson('/api/whois/' . rawurlencode("bad\r\ndomain.com"))->assertUnprocessable();
$this->assertSame([], $this->calls);
}
}
Il terzo test documenta la protezione contro l'iniezione: un dominio contenente un'interruzione di riga viene rifiutato con un codice 422 prima di qualunque contatto con la rete, come conferma l'elenco vuoto delle chiamate al trasporto.
Considerazioni finali
Il modulo che abbiamo costruito sfrutta ciò che Laravel offre per risolvere i problemi tipici di un client WHOIS in un'applicazione web: cache e lock condivisi tra i worker, rate limiting globale, dependency injection per la testabilità, comandi Artisan e pianificazione per le attività ricorrenti. Va comunque collocato nel contesto attuale. Per i domini generici RDAP è il protocollo di riferimento, con risposte JSON via HTTPS e un registro di bootstrap pubblicato da IANA per individuare il server; grazie all'interfaccia WhoisTransport e al client HTTP di Laravel è semplice aggiungere un percorso RDAP da tentare per primo, ricorrendo a WHOIS per i TLD che non lo offrono.
Dopo il GDPR molti dati personali degli intestatari sono oscurati, e diciture come REDACTED FOR PRIVACY sono normali. Infine, le condizioni d'uso dei server WHOIS vietano in genere l'interrogazione massiva e l'uso commerciale dei dati: il rate limiter, la cache e la pausa nel comando di monitoraggio sono ciò che permette al modulo di funzionare a lungo senza essere bloccato.