Effettuare query DNS in Laravel

Il DNS (Domain Name System) è il sistema che traduce i nomi di dominio in indirizzi IP e viceversa, ed è alla base del funzionamento di Internet. In molte applicazioni web è necessario effettuare query DNS direttamente dal backend: per validare indirizzi email, verificare la configurazione di un dominio, controllare la presenza di record SPF, DKIM o MX prima di inviare messaggi, oppure semplicemente per diagnosticare problemi di rete. Laravel non dispone di un facade dedicato esclusivamente alle query DNS, ma offre diversi strumenti — nativi di PHP e integrati nell'ecosistema del framework — che permettono di eseguire queste operazioni in modo pulito ed efficace. In questo articolo vedremo come sfruttarli al meglio, partendo dalle funzioni PHP di base fino ad arrivare a soluzioni più strutturate con classi dedicate e test automatici.

Le funzioni PHP native per il DNS

PHP mette a disposizione alcune funzioni native per interrogare il DNS. Le più rilevanti sono dns_get_record(), checkdnsrr() e gethostbyname() / gethostbyaddr(). Queste funzioni sono disponibili in qualsiasi ambiente PHP senza dipendenze aggiuntive e possono essere usate direttamente in Laravel come in qualsiasi altro progetto PHP.

La funzione dns_get_record() è la più potente: accetta un nome di dominio, un tipo di record DNS (o la costante DNS_ALL per recuperarli tutti) e restituisce un array associativo con i risultati. I tipi di record supportati includono DNS_A, DNS_AAAA, DNS_CNAME, DNS_MX, DNS_NS, DNS_TXT, DNS_SOA e altri.

<?php

// Recupera tutti i record MX per un dominio
$records = dns_get_record('example.com', DNS_MX);

foreach ($records as $record) {
    // Stampa il target e la priorità di ogni record MX
    echo $record['target'] . ' (priorità: ' . $record['pri'] . ')';
}

La funzione checkdnsrr() è più semplice: restituisce true se esiste almeno un record del tipo specificato per il dominio dato, false altrimenti. È utile per una verifica rapida senza dover analizzare i dettagli del record.

<?php

// Controlla se esiste un record MX per il dominio
if (checkdnsrr('example.com', 'MX')) {
    // Il dominio accetta posta elettronica
    echo 'Il dominio ha un record MX.';
} else {
    echo 'Nessun record MX trovato.';
}

Le funzioni gethostbyname() e gethostbyaddr() effettuano rispettivamente una risoluzione diretta (da nome a IP) e inversa (da IP a nome). Sono le più semplici da usare ma anche le meno flessibili, poiché non permettono di specificare il tipo di record.

<?php

// Risoluzione diretta: nome a IPv4
$ipAddress = gethostbyname('example.com');

// Risoluzione inversa: IP a nome host
$hostname = gethostbyaddr('93.184.216.34');

Creare un servizio DNS in Laravel

Usare le funzioni PHP native direttamente nei controller o nei job non è una buona pratica: rende il codice difficile da testare e da mantenere. La soluzione corretta in Laravel è incapsulare la logica DNS in un servizio dedicato, registrarlo nel container e iniettarlo dove necessario tramite dependency injection.

Creiamo prima la classe del servizio nella directory app/Services:

<?php

namespace App\Services;

class DnsLookupService
{
    /**
     * Restituisce i record DNS di un dominio per il tipo specificato.
     */
    public function getRecords(string $domain, int $type = DNS_ALL): array
    {
        // Recupera i record dal resolver di sistema
        $results = dns_get_record($domain, $type);

        // Restituisce un array vuoto in caso di errore
        return $results !== false ? $results : [];
    }

    /**
     * Controlla se esiste almeno un record del tipo specificato.
     */
    public function hasRecord(string $domain, string $type): bool
    {
        return checkdnsrr($domain, $type);
    }

    /**
     * Restituisce il primo indirizzo IPv4 associato al dominio.
     */
    public function resolveIpv4(string $domain): ?string
    {
        $records = $this->getRecords($domain, DNS_A);

        // Prende il primo risultato disponibile
        return $records[0]['ip'] ?? null;
    }

    /**
     * Restituisce il primo indirizzo IPv6 associato al dominio.
     */
    public function resolveIpv6(string $domain): ?string
    {
        $records = $this->getRecords($domain, DNS_AAAA);

        // Prende il primo risultato disponibile
        return $records[0]['ipv6'] ?? null;
    }

    /**
     * Restituisce i record MX ordinati per priorità crescente.
     */
    public function getMxRecords(string $domain): array
    {
        $records = $this->getRecords($domain, DNS_MX);

        // Ordina per priorità (campo pri): valore più basso = priorità più alta
        usort($records, fn($a, $b) => $a['pri'] <=> $b['pri']);

        return $records;
    }

    /**
     * Restituisce tutti i record TXT del dominio.
     */
    public function getTxtRecords(string $domain): array
    {
        return $this->getRecords($domain, DNS_TXT);
    }

    /**
     * Restituisce i nameserver (NS) del dominio.
     */
    public function getNameservers(string $domain): array
    {
        return $this->getRecords($domain, DNS_NS);
    }

    /**
     * Effettua la risoluzione inversa di un indirizzo IP.
     */
    public function reverseResolve(string $ipAddress): ?string
    {
        $hostname = gethostbyaddr($ipAddress);

        // gethostbyaddr restituisce l'IP originale se la risoluzione fallisce
        return $hostname !== $ipAddress ? $hostname : null;
    }
}

Ora registriamo il servizio in un service provider. Possiamo usare l'AppServiceProvider già esistente oppure crearne uno dedicato con php artisan make:provider DnsServiceProvider.

<?php

namespace App\Providers;

use App\Services\DnsLookupService;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Registra il servizio come singleton nel container
        $this->app->singleton(DnsLookupService::class, function () {
            return new DnsLookupService();
        });
    }

    public function boot(): void
    {
        //
    }
}

Utilizzare il servizio in un controller

Una volta registrato nel container, il servizio può essere iniettato automaticamente da Laravel tramite il costruttore del controller o direttamente nel metodo dell'azione (route model binding e dependency injection automatica funzionano anche per i servizi applicativi).

<?php

namespace App\Http\Controllers;

use App\Services\DnsLookupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class DnsController extends Controller
{
    public function __construct(
        // Iniezione automatica tramite il container di Laravel
        private readonly DnsLookupService $dnsService
    ) {}

    /**
     * Restituisce un riepilogo DNS completo per il dominio richiesto.
     */
    public function lookup(Request $request): JsonResponse
    {
        $request->validate([
            // Il campo domain è obbligatorio e deve essere un dominio valido
            'domain' => ['required', 'string', 'max:253'],
        ]);

        $domain = $request->input('domain');

        $result = [
            'domain'      => $domain,
            'ipv4'        => $this->dnsService->resolveIpv4($domain),
            'ipv6'        => $this->dnsService->resolveIpv6($domain),
            'mx'          => $this->dnsService->getMxRecords($domain),
            'txt'         => $this->dnsService->getTxtRecords($domain),
            'nameservers' => $this->dnsService->getNameservers($domain),
        ];

        return response()->json($result);
    }

    /**
     * Verifica la presenza di un record MX per validare un dominio email.
     */
    public function checkMx(Request $request): JsonResponse
    {
        $request->validate([
            'domain' => ['required', 'string'],
        ]);

        $domain = $request->input('domain');
        $hasMx  = $this->dnsService->hasRecord($domain, 'MX');

        return response()->json([
            'domain' => $domain,
            'has_mx' => $hasMx,
        ]);
    }

    /**
     * Effettua la risoluzione inversa di un indirizzo IP.
     */
    public function reverseResolve(Request $request): JsonResponse
    {
        $request->validate([
            'ip' => ['required', 'ip'],
        ]);

        $ip       = $request->input('ip');
        $hostname = $this->dnsService->reverseResolve($ip);

        return response()->json([
            'ip'       => $ip,
            'hostname' => $hostname,
        ]);
    }
}

Le rotte corrispondenti possono essere definite in routes/api.php:

<?php

use App\Http\Controllers\DnsController;
use Illuminate\Support\Facades\Route;

// Gruppo di rotte per le operazioni DNS
Route::prefix('dns')->group(function () {
    Route::get('/lookup', [DnsController::class, 'lookup']);
    Route::get('/check-mx', [DnsController::class, 'checkMx']);
    Route::get('/reverse', [DnsController::class, 'reverseResolve']);
});

Validazione email con verifica DNS

Uno degli utilizzi più comuni delle query DNS in un'applicazione web è la validazione avanzata degli indirizzi email: oltre a verificare il formato con una regex o con la regola email di Laravel, è possibile controllare che il dominio dell'indirizzo abbia effettivamente un record MX, il che significa che è configurato per ricevere posta. Questo riduce drasticamente l'inserimento di email inesistenti o con errori di digitazione nel dominio.

Creiamo una regola di validazione personalizzata con il comando Artisan:

php artisan make:rule DomainHasMxRecord
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class DomainHasMxRecord implements ValidationRule
{
    /**
     * Verifica che il dominio dell'email abbia almeno un record MX.
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        // Estrae il dominio dalla parte destra dell'indirizzo email
        $domain = substr(strrchr($value, '@'), 1);

        if (empty($domain)) {
            $fail("Il campo :attribute non contiene un dominio valido.");
            return;
        }

        // Controlla la presenza di record MX tramite DNS
        if (!checkdnsrr($domain, 'MX')) {
            $fail("Il dominio '$domain' non ha record MX configurati.");
        }
    }
}

La regola può essere usata in un form request o direttamente in un controller:

<?php

namespace App\Http\Controllers;

use App\Rules\DomainHasMxRecord;
use Illuminate\Http\Request;

class RegistrationController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            // Valida il formato e poi controlla il DNS
            'email' => ['required', 'email:rfc,dns', new DomainHasMxRecord()],
            'name'  => ['required', 'string', 'max:255'],
        ]);

        // Procede con la registrazione dell'utente
    }
}

Verificare record SPF e DKIM tramite TXT

I record SPF (Sender Policy Framework) e DKIM (DomainKeys Identified Mail) sono entrambi pubblicati come record TXT nel DNS. SPF specifica quali server sono autorizzati a inviare email per conto di un dominio; DKIM pubblica la chiave pubblica usata per verificare la firma crittografica dei messaggi. Verificare questi record è utile per diagnosticare problemi di deliverability nella propria applicazione.

<?php

namespace App\Services;

class EmailAuthDnsService
{
    /**
     * Recupera il record SPF del dominio, se presente.
     */
    public function getSpfRecord(string $domain): ?string
    {
        $txtRecords = dns_get_record($domain, DNS_TXT);

        if ($txtRecords === false) {
            return null;
        }

        foreach ($txtRecords as $record) {
            $text = $record['txt'] ?? '';

            // I record SPF iniziano sempre con "v=spf1"
            if (str_starts_with($text, 'v=spf1')) {
                return $text;
            }
        }

        return null;
    }

    /**
     * Recupera la chiave pubblica DKIM per il selettore specificato.
     */
    public function getDkimRecord(string $domain, string $selector): ?string
    {
        // Il record DKIM si trova sempre su un sottodominio con questo formato
        $dkimDomain = "{$selector}._domainkey.{$domain}";
        $txtRecords = dns_get_record($dkimDomain, DNS_TXT);

        if ($txtRecords === false || empty($txtRecords)) {
            return null;
        }

        // Restituisce il testo del primo record trovato
        return $txtRecords[0]['txt'] ?? null;
    }

    /**
     * Verifica se il dominio ha un record DMARC configurato.
     */
    public function hasDmarcRecord(string $domain): bool
    {
        // DMARC si trova sempre sul sottodominio _dmarc
        $dmarcDomain = "_dmarc.{$domain}";
        $txtRecords  = dns_get_record($dmarcDomain, DNS_TXT);

        if ($txtRecords === false || empty($txtRecords)) {
            return false;
        }

        foreach ($txtRecords as $record) {
            // I record DMARC iniziano con "v=DMARC1"
            if (str_starts_with($record['txt'] ?? '', 'v=DMARC1')) {
                return true;
            }
        }

        return false;
    }
}

Eseguire query DNS nei job e nelle code

Le query DNS possono essere lente, soprattutto quando il resolver di sistema deve contattare server remoti o quando si interrogano domini che non esistono (il timeout può richiedere diversi secondi). Per questo motivo, in un'applicazione Laravel è consigliabile spostare le query DNS onerose all'interno di job eseguiti in background tramite il sistema di code.

<?php

namespace App\Jobs;

use App\Models\Domain;
use App\Services\DnsLookupService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class CheckDomainDnsJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    // Numero massimo di tentativi in caso di errore
    public int $tries = 3;

    // Timeout in secondi per ciascun tentativo
    public int $timeout = 30;

    public function __construct(
        private readonly Domain $domain
    ) {}

    /**
     * Esegue la verifica DNS del dominio in background.
     */
    public function handle(DnsLookupService $dnsService): void
    {
        // Recupera l'indirizzo IPv4 del dominio
        $ipv4 = $dnsService->resolveIpv4($this->domain->name);

        // Verifica la presenza di record MX
        $hasMx = $dnsService->hasRecord($this->domain->name, 'MX');

        // Recupera i record TXT per l'analisi SPF/DKIM/DMARC
        $txtRecords = $dnsService->getTxtRecords($this->domain->name);

        // Aggiorna il modello con i dati raccolti
        $this->domain->update([
            'resolved_ip'  => $ipv4,
            'has_mx'       => $hasMx,
            'txt_records'  => json_encode($txtRecords),
            'last_checked' => now(),
        ]);
    }
}

Il job può essere dispatchato da un controller o da un comando Artisan:

<?php

// Dispatch immediato nella coda predefinita
CheckDomainDnsJob::dispatch($domain);

// Dispatch con ritardo di 5 minuti
CheckDomainDnsJob::dispatch($domain)->delay(now()->addMinutes(5));

// Dispatch su una coda specifica
CheckDomainDnsJob::dispatch($domain)->onQueue('dns-checks');

Cache dei risultati DNS

Il DNS dispone già di un meccanismo di caching basato sul TTL (Time To Live) di ciascun record, ma a livello applicativo è comunque utile introdurre un ulteriore layer di cache per evitare query ripetute a breve distanza di tempo. Laravel mette a disposizione il facade Cache che supporta diversi driver (Redis, Memcached, file, database). Integrare la cache nel servizio DNS è semplice e migliora significativamente le prestazioni quando lo stesso dominio viene interrogato più volte nella stessa sessione o in sessioni ravvicinate.

<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;

class CachedDnsLookupService extends DnsLookupService
{
    // Durata della cache in secondi (corrisponde a un TTL DNS tipico)
    private int $cacheTtl = 300;

    /**
     * Restituisce i record DNS con caching automatico.
     */
    public function getRecords(string $domain, int $type = DNS_ALL): array
    {
        // Genera una chiave univoca basata su dominio e tipo di record
        $cacheKey = "dns:{$domain}:{$type}";

        return Cache::remember($cacheKey, $this->cacheTtl, function () use ($domain, $type) {
            // Esegue la query DNS solo se la cache è scaduta o assente
            return parent::getRecords($domain, $type);
        });
    }

    /**
     * Invalida la cache per un dominio specifico.
     */
    public function flushDomainCache(string $domain): void
    {
        // Tipi di record comuni da invalidare
        $types = [DNS_ALL, DNS_A, DNS_AAAA, DNS_MX, DNS_TXT, DNS_NS, DNS_CNAME];

        foreach ($types as $type) {
            Cache::forget("dns:{$domain}:{$type}");
        }
    }
}

Scrivere test per il servizio DNS

Le funzioni PHP native per il DNS non sono facilmente testabili in isolamento perché dipendono dalla rete e dal resolver di sistema. La soluzione standard in Laravel è creare un'interfaccia per il servizio DNS e iniettare un mock nei test. In alternativa, si può usare Mockery direttamente per sostituire il servizio concreto con una versione controllata che restituisce dati predefiniti.

Definiamo prima un'interfaccia per il servizio:

<?php

namespace App\Contracts;

interface DnsLookupServiceInterface
{
    public function getRecords(string $domain, int $type): array;
    public function hasRecord(string $domain, string $type): bool;
    public function resolveIpv4(string $domain): ?string;
    public function getMxRecords(string $domain): array;
    public function getTxtRecords(string $domain): array;
}

Aggiorniamo il service provider per registrare l'implementazione concreta legata all'interfaccia:

<?php

use App\Contracts\DnsLookupServiceInterface;
use App\Services\DnsLookupService;

// Lega l'interfaccia all'implementazione concreta
$this->app->bind(DnsLookupServiceInterface::class, DnsLookupService::class);

Ora possiamo scrivere un test che usa un mock dell'interfaccia:

<?php

namespace Tests\Unit;

use App\Contracts\DnsLookupServiceInterface;
use App\Rules\DomainHasMxRecord;
use Mockery;
use Tests\TestCase;

class DnsLookupServiceTest extends TestCase
{
    public function test_resolves_ipv4_correctly(): void
    {
        // Crea un mock dell'interfaccia DNS
        $mockService = Mockery::mock(DnsLookupServiceInterface::class);

        // Definisce il comportamento atteso
        $mockService->shouldReceive('resolveIpv4')
            ->with('example.com')
            ->once()
            ->andReturn('93.184.216.34');

        // Inietta il mock nel container di Laravel
        $this->app->instance(DnsLookupServiceInterface::class, $mockService);

        // Risolve il servizio e verifica il risultato
        $service = app(DnsLookupServiceInterface::class);
        $ip      = $service->resolveIpv4('example.com');

        $this->assertEquals('93.184.216.34', $ip);
    }

    public function test_returns_null_when_no_ipv4_found(): void
    {
        $mockService = Mockery::mock(DnsLookupServiceInterface::class);

        // Simula un dominio senza record A
        $mockService->shouldReceive('resolveIpv4')
            ->with('nonexistent.invalid')
            ->once()
            ->andReturn(null);

        $this->app->instance(DnsLookupServiceInterface::class, $mockService);

        $service = app(DnsLookupServiceInterface::class);
        $result  = $service->resolveIpv4('nonexistent.invalid');

        $this->assertNull($result);
    }

    public function test_mx_records_are_sorted_by_priority(): void
    {
        // Record MX non ordinati da restituire come risposta del mock
        $unsortedRecords = [
            ['target' => 'mx2.example.com', 'pri' => 20],
            ['target' => 'mx1.example.com', 'pri' => 10],
            ['target' => 'mx3.example.com', 'pri' => 30],
        ];

        $mockService = Mockery::mock(DnsLookupServiceInterface::class);
        $mockService->shouldReceive('getMxRecords')
            ->with('example.com')
            ->once()
            ->andReturn($unsortedRecords);

        $this->app->instance(DnsLookupServiceInterface::class, $mockService);

        $service = app(DnsLookupServiceInterface::class);
        $records = $service->getMxRecords('example.com');

        // Verifica che il primo record abbia la priorità più alta (valore più basso)
        $this->assertEquals('mx2.example.com', $records[0]['target']);
    }

    protected function tearDown(): void
    {
        // Chiude i mock di Mockery dopo ogni test
        Mockery::close();
        parent::tearDown();
    }
}

Considerazioni sulle prestazioni e sui timeout

Le query DNS tramite le funzioni PHP native utilizzano il resolver di sistema configurato nel file /etc/resolv.conf del server. In ambienti di produzione, è consigliabile verificare che il resolver sia configurato correttamente e che i timeout siano adeguati al tipo di applicazione. Su server Linux è possibile personalizzare il timeout e il numero di tentativi aggiungendo le direttive timeout e attempts nel file di configurazione del resolver:

# /etc/resolv.conf
nameserver 1.1.1.1
nameserver 8.8.8.8
options timeout:2 attempts:3

PHP non espone un'opzione nativa per impostare il timeout delle query DNS a livello di codice. Se si ha la necessità di controllare il timeout in modo preciso, una soluzione alternativa consiste nel wrappare la query in un processo con timeout usando proc_open() oppure delegare le query a un client DNS esterno come il pacchetto reactphp/dns che supporta query asincrone.

Per applicazioni che effettuano molte query DNS (ad esempio sistemi di monitoraggio o strumenti di analisi di domini) è fortemente consigliato usare Redis come driver di cache e impostare un TTL allineato ai TTL reali dei record DNS. Un approccio efficace è recuperare il TTL direttamente dal campo ttl restituito da dns_get_record() e usarlo come durata della cache:

<?php

/**
 * Recupera i record e li memorizza nella cache usando il TTL del DNS.
 */
public function getRecordsWithDynamicTtl(string $domain, int $type): array
{
    $cacheKey = "dns:{$domain}:{$type}";

    // Controlla prima se i dati sono già in cache
    if (Cache::has($cacheKey)) {
        return Cache::get($cacheKey);
    }

    $records = dns_get_record($domain, $type);

    if ($records === false || empty($records)) {
        return [];
    }

    // Usa il TTL del primo record come durata della cache
    $ttl = $records[0]['ttl'] ?? 300;

    Cache::put($cacheKey, $records, $ttl);

    return $records;
}

Conclusioni

PHP e Laravel offrono tutti gli strumenti necessari per effettuare query DNS in modo efficace: le funzioni native come dns_get_record() e checkdnsrr() coprono la maggior parte dei casi d'uso, mentre l'architettura di Laravel — con il suo container, le interfacce, le regole di validazione personalizzate e il sistema di code — permette di strutturare il codice in modo pulito, testabile e scalabile. Che si tratti di validare indirizzi email, diagnosticare configurazioni DNS o costruire strumenti di monitoraggio, integrare le query DNS in un'applicazione Laravel richiede poche classi ben organizzate e una gestione attenta della cache e dei timeout.