Verificare la configurazione del record PTR di un dominio con Go

Il record PTR (Pointer Record) è uno degli elementi più importanti e spesso più trascurati della configurazione DNS di un dominio. Mentre i record A e AAAA mappano un nome di dominio a un indirizzo IP, il record PTR svolge l'operazione inversa: associa un indirizzo IP a un nome di dominio. Questa operazione, nota come reverse DNS lookup, è fondamentale per molte applicazioni, in particolare per i server di posta elettronica, dove la presenza e la corretta configurazione del record PTR è spesso un requisito per evitare che i messaggi vengano contrassegnati come spam.

In questo articolo vedremo come implementare in Go uno strumento completo per verificare la configurazione del record PTR di un dominio, analizzando le librerie standard del linguaggio, le tecniche di risoluzione DNS e le best practice per la gestione degli errori.

Cos'è un record PTR e perché è importante

Il record PTR è definito nella zona DNS speciale in-addr.arpa per gli indirizzi IPv4 e ip6.arpa per gli indirizzi IPv6. Quando si effettua una query PTR su un indirizzo IP come 192.0.2.1, la query viene effettivamente inoltrata al dominio 1.2.0.192.in-addr.arpa, ottenendo come risposta un FQDN (Fully Qualified Domain Name) come ad esempio mail.example.com.

L'importanza del record PTR risiede principalmente in tre aspetti: la verifica dell'autenticità del mittente nelle comunicazioni email (FCrDNS, Forward-Confirmed Reverse DNS), il logging e l'analisi del traffico di rete, e la conformità con le politiche di sicurezza di molti servizi online che richiedono una corrispondenza tra IP e dominio.

Il package net di Go per le risoluzioni DNS

Il package net della libreria standard di Go offre tutte le funzionalità necessarie per effettuare query DNS, inclusa la risoluzione inversa. Le funzioni principali che utilizzeremo sono net.LookupAddr per ottenere i nomi associati a un indirizzo IP e net.LookupHost per la risoluzione diretta, utile per la verifica FCrDNS.

Iniziamo con un esempio base che mostra come effettuare una semplice query PTR:

package main

import (
    "fmt"
    "net"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Println("Utilizzo: ptr-check <indirizzo-ip>")
        os.Exit(1)
    }

    ipAddress := os.Args[1]

    // Esecuzione della query PTR sull'indirizzo IP fornito
    names, err := net.LookupAddr(ipAddress)
    if err != nil {
        fmt.Printf("Errore durante la risoluzione PTR: %v\n", err)
        os.Exit(1)
    }

    fmt.Printf("Record PTR trovati per %s:\n", ipAddress)
    for _, name := range names {
        fmt.Printf("  - %s\n", name)
    }
}

Questo programma accetta un indirizzo IP come argomento da riga di comando ed esegue una query PTR. La funzione net.LookupAddr restituisce uno slice di stringhe, poiché un indirizzo IP può teoricamente avere più record PTR associati, anche se nella pratica è raro e generalmente sconsigliato.

Risoluzione del dominio e verifica del PTR

Per verificare la configurazione completa del record PTR di un dominio, dobbiamo prima risolvere il dominio nel suo indirizzo IP, quindi effettuare la query PTR su quell'indirizzo e infine verificare che il nome restituito corrisponda al dominio originale. Questo processo è noto come Forward-Confirmed Reverse DNS.

package main

import (
    "fmt"
    "net"
    "strings"
)

// PTRResult contiene il risultato della verifica del record PTR
type PTRResult struct {
    Domain     string
    IPAddress  string
    PTRRecords []string
    IsValid    bool
    Error      error
}

func checkPTRRecord(domain string) *PTRResult {
    result := &PTRResult{
        Domain: domain,
    }

    // Risoluzione del dominio in indirizzo IP
    ips, err := net.LookupIP(domain)
    if err != nil {
        result.Error = fmt.Errorf("impossibile risolvere il dominio: %w", err)
        return result
    }

    if len(ips) == 0 {
        result.Error = fmt.Errorf("nessun indirizzo IP trovato per il dominio")
        return result
    }

    // Utilizzo del primo indirizzo IP disponibile
    result.IPAddress = ips[0].String()

    // Esecuzione della query PTR
    names, err := net.LookupAddr(result.IPAddress)
    if err != nil {
        result.Error = fmt.Errorf("impossibile risolvere il record PTR: %w", err)
        return result
    }

    result.PTRRecords = names

    // Verifica della corrispondenza tra dominio e PTR
    for _, name := range names {
        // Rimozione del punto finale dal FQDN
        cleanName := strings.TrimSuffix(name, ".")
        if strings.EqualFold(cleanName, domain) {
            result.IsValid = true
            break
        }
    }

    return result
}

func main() {
    domain := "example.com"
    result := checkPTRRecord(domain)

    if result.Error != nil {
        fmt.Printf("Errore: %v\n", result.Error)
        return
    }

    fmt.Printf("Dominio: %s\n", result.Domain)
    fmt.Printf("Indirizzo IP: %s\n", result.IPAddress)
    fmt.Printf("Record PTR: %v\n", result.PTRRecords)
    fmt.Printf("PTR valido: %v\n", result.IsValid)
}

In questo esempio abbiamo creato una struttura PTRResult che incapsula tutte le informazioni rilevanti sulla verifica. La funzione checkPTRRecord esegue le tre operazioni fondamentali: risoluzione del dominio, query PTR e verifica della corrispondenza. L'utilizzo di strings.EqualFold garantisce un confronto case-insensitive, dato che i nomi di dominio sono insensibili al caso.

Gestione di indirizzi IPv4 e IPv6

Una verifica completa del record PTR deve gestire correttamente sia indirizzi IPv4 che IPv6. Il package net di Go gestisce in modo trasparente entrambi i tipi di indirizzi, ma è utile distinguerli per fornire un report più dettagliato all'utente.

package main

import (
    "fmt"
    "net"
)

// IPInfo contiene informazioni su un indirizzo IP
type IPInfo struct {
    Address  string
    Version  string
    PTR      []string
    HasError bool
    ErrorMsg string
}

func analyzeDomain(domain string) []IPInfo {
    var results []IPInfo

    // Recupero di tutti gli indirizzi IP associati al dominio
    ips, err := net.LookupIP(domain)
    if err != nil {
        return results
    }

    for _, ip := range ips {
        info := IPInfo{
            Address: ip.String(),
        }

        // Determinazione della versione del protocollo IP
        if ip.To4() != nil {
            info.Version = "IPv4"
        } else {
            info.Version = "IPv6"
        }

        // Esecuzione della query PTR per l'indirizzo
        names, err := net.LookupAddr(ip.String())
        if err != nil {
            info.HasError = true
            info.ErrorMsg = err.Error()
        } else {
            info.PTR = names
        }

        results = append(results, info)
    }

    return results
}

func main() {
    domain := "google.com"
    results := analyzeDomain(domain)

    fmt.Printf("Analisi PTR per %s:\n\n", domain)
    for _, info := range results {
        fmt.Printf("Indirizzo: %s (%s)\n", info.Address, info.Version)
        if info.HasError {
            fmt.Printf("  Errore: %s\n", info.ErrorMsg)
        } else {
            for _, ptr := range info.PTR {
                fmt.Printf("  PTR: %s\n", ptr)
            }
        }
        fmt.Println()
    }
}

Il metodo To4() di net.IP restituisce un valore non nil solo se l'indirizzo è effettivamente un IPv4, permettendoci di distinguere facilmente tra le due versioni del protocollo. Questo è particolarmente utile perché molti domini moderni hanno sia record A che record AAAA, e ciascuno richiede una verifica PTR separata.

Utilizzo di un resolver DNS personalizzato

In alcuni scenari può essere necessario utilizzare un server DNS specifico invece di quello configurato a livello di sistema operativo. Questo è particolarmente utile per il debugging o per verificare la propagazione DNS attraverso diversi resolver. Go permette di creare un net.Resolver personalizzato con un dialer custom.

package main

import (
    "context"
    "fmt"
    "net"
    "time"
)

func createCustomResolver(dnsServer string) *net.Resolver {
    return &net.Resolver{
        PreferGo: true,
        Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
            dialer := net.Dialer{
                Timeout: 5 * time.Second,
            }
            // Utilizzo del server DNS specificato
            return dialer.DialContext(ctx, network, dnsServer)
        },
    }
}

func lookupPTRWithResolver(ipAddress, dnsServer string) ([]string, error) {
    resolver := createCustomResolver(dnsServer)

    // Creazione di un contesto con timeout
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    return resolver.LookupAddr(ctx, ipAddress)
}

func main() {
    // Utilizzo del DNS pubblico di Cloudflare
    dnsServer := "1.1.1.1:53"
    ipAddress := "8.8.8.8"

    names, err := lookupPTRWithResolver(ipAddress, dnsServer)
    if err != nil {
        fmt.Printf("Errore: %v\n", err)
        return
    }

    fmt.Printf("Record PTR per %s tramite %s:\n", ipAddress, dnsServer)
    for _, name := range names {
        fmt.Printf("  - %s\n", name)
    }
}

L'opzione PreferGo: true indica al resolver di utilizzare l'implementazione Go pura invece delle chiamate cgo al sistema, garantendo un comportamento consistente su tutte le piattaforme. Il timeout configurato nel dialer e nel context impedisce che il programma si blocchi indefinitamente in caso di server DNS non raggiungibili.

Implementazione di una verifica completa

Mettiamo ora insieme tutti i concetti visti finora per creare uno strumento completo che esegue una verifica approfondita della configurazione PTR di un dominio. Lo strumento gestirà più indirizzi IP, sia IPv4 che IPv6, eseguirà la verifica FCrDNS e fornirà un report dettagliato.

package main

import (
    "context"
    "fmt"
    "net"
    "os"
    "strings"
    "time"
)

// PTRCheck rappresenta il risultato della verifica per un singolo IP
type PTRCheck struct {
    IPAddress    string
    IPVersion    string
    PTRRecords   []string
    ForwardIPs   []string
    FCrDNSValid  bool
    DomainMatch  bool
    Error        error
}

// DomainReport contiene il report completo per un dominio
type DomainReport struct {
    Domain  string
    Checks  []PTRCheck
    Summary string
}

func performPTRCheck(domain string, ip net.IP, timeout time.Duration) PTRCheck {
    check := PTRCheck{
        IPAddress: ip.String(),
    }

    if ip.To4() != nil {
        check.IPVersion = "IPv4"
    } else {
        check.IPVersion = "IPv6"
    }

    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    resolver := net.DefaultResolver

    // Risoluzione PTR
    names, err := resolver.LookupAddr(ctx, ip.String())
    if err != nil {
        check.Error = fmt.Errorf("query PTR fallita: %w", err)
        return check
    }
    check.PTRRecords = names

    // Verifica corrispondenza con il dominio
    for _, name := range names {
        cleanName := strings.TrimSuffix(name, ".")
        if strings.EqualFold(cleanName, domain) {
            check.DomainMatch = true
            break
        }
    }

    // Verifica FCrDNS: il PTR deve risolvere all'IP originale
    if len(names) > 0 {
        ptrName := strings.TrimSuffix(names[0], ".")
        forwardIPs, err := resolver.LookupHost(ctx, ptrName)
        if err == nil {
            check.ForwardIPs = forwardIPs
            for _, forwardIP := range forwardIPs {
                if forwardIP == ip.String() {
                    check.FCrDNSValid = true
                    break
                }
            }
        }
    }

    return check
}

func generateReport(domain string) *DomainReport {
    report := &DomainReport{
        Domain: domain,
    }

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    ips, err := net.DefaultResolver.LookupIP(ctx, "ip", domain)
    if err != nil {
        report.Summary = fmt.Sprintf("Impossibile risolvere il dominio: %v", err)
        return report
    }

    // Esecuzione delle verifiche per ogni indirizzo IP
    for _, ip := range ips {
        check := performPTRCheck(domain, ip, 5*time.Second)
        report.Checks = append(report.Checks, check)
    }

    // Generazione del sommario
    validCount := 0
    for _, check := range report.Checks {
        if check.FCrDNSValid && check.DomainMatch {
            validCount++
        }
    }
    report.Summary = fmt.Sprintf("%d/%d indirizzi con configurazione PTR valida",
        validCount, len(report.Checks))

    return report
}

func printReport(report *DomainReport) {
    fmt.Printf("=== Report PTR per %s ===\n\n", report.Domain)

    for i, check := range report.Checks {
        fmt.Printf("[%d] Indirizzo: %s (%s)\n", i+1, check.IPAddress, check.IPVersion)

        if check.Error != nil {
            fmt.Printf("    Errore: %v\n", check.Error)
            continue
        }

        fmt.Printf("    Record PTR:\n")
        for _, ptr := range check.PTRRecords {
            fmt.Printf("      - %s\n", ptr)
        }

        fmt.Printf("    Corrispondenza dominio: %v\n", check.DomainMatch)
        fmt.Printf("    FCrDNS valido: %v\n", check.FCrDNSValid)

        if len(check.ForwardIPs) > 0 {
            fmt.Printf("    IP da risoluzione diretta: %v\n", check.ForwardIPs)
        }
        fmt.Println()
    }

    fmt.Printf("Riepilogo: %s\n", report.Summary)
}

func main() {
    if len(os.Args) < 2 {
        fmt.Println("Utilizzo: ptr-checker <dominio>")
        os.Exit(1)
    }

    domain := os.Args[1]
    report := generateReport(domain)
    printReport(report)
}

Questo strumento completo esegue una serie di verifiche per ogni indirizzo IP associato al dominio. La verifica FCrDNS è particolarmente importante perché molti servizi, in particolare i provider di posta elettronica, richiedono che il record PTR risolva a un nome che a sua volta risolve allo stesso indirizzo IP originale. Senza questa corrispondenza bidirezionale, le email inviate dal server potrebbero essere rifiutate o contrassegnate come spam.

Gestione degli errori e dei timeout

Le query DNS possono fallire per molteplici ragioni: timeout di rete, server DNS non raggiungibili, record inesistenti, o errori di configurazione. Una gestione robusta degli errori è essenziale per qualsiasi strumento di verifica DNS. Go offre il pattern dei wrapped errors tramite fmt.Errorf con il verbo %w, che permette di mantenere la catena degli errori per analisi successive.

package main

import (
    "context"
    "errors"
    "fmt"
    "net"
    "time"
)

// Errori personalizzati per la verifica PTR
var (
    ErrDomainNotFound  = errors.New("dominio non trovato")
    ErrPTRNotFound     = errors.New("record PTR non trovato")
    ErrTimeout         = errors.New("timeout durante la query")
    ErrInvalidPTR      = errors.New("configurazione PTR non valida")
)

func robustPTRLookup(domain string) ([]string, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // Prima fase: risoluzione del dominio
    ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", domain)
    if err != nil {
        // Verifica del tipo di errore specifico
        var dnsErr *net.DNSError
        if errors.As(err, &dnsErr) {
            if dnsErr.IsNotFound {
                return nil, fmt.Errorf("%w: %s", ErrDomainNotFound, domain)
            }
            if dnsErr.IsTimeout {
                return nil, fmt.Errorf("%w: %s", ErrTimeout, domain)
            }
        }
        return nil, fmt.Errorf("errore di risoluzione: %w", err)
    }

    if len(ips) == 0 {
        return nil, ErrDomainNotFound
    }

    // Seconda fase: query PTR
    names, err := net.DefaultResolver.LookupAddr(ctx, ips[0].String())
    if err != nil {
        var dnsErr *net.DNSError
        if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
            return nil, fmt.Errorf("%w per %s", ErrPTRNotFound, ips[0])
        }
        return nil, fmt.Errorf("errore PTR: %w", err)
    }

    return names, nil
}

func main() {
    domains := []string{
        "google.com",
        "example.invalid",
        "github.com",
    }

    for _, domain := range domains {
        names, err := robustPTRLookup(domain)
        if err != nil {
            // Gestione differenziata degli errori
            switch {
            case errors.Is(err, ErrDomainNotFound):
                fmt.Printf("[%s] Dominio non esistente\n", domain)
            case errors.Is(err, ErrPTRNotFound):
                fmt.Printf("[%s] Record PTR mancante\n", domain)
            case errors.Is(err, ErrTimeout):
                fmt.Printf("[%s] Timeout della query\n", domain)
            default:
                fmt.Printf("[%s] Errore generico: %v\n", domain, err)
            }
            continue
        }

        fmt.Printf("[%s] PTR: %v\n", domain, names)
    }
}

L'utilizzo di net.DNSError tramite errors.As permette di accedere ai campi specifici dell'errore DNS, come IsNotFound e IsTimeout, per fornire messaggi di errore più precisi e informativi. La definizione di errori personalizzati (sentinel errors) facilita inoltre la gestione differenziata degli errori da parte del codice chiamante.

Considerazioni finali

La verifica del record PTR è un'operazione apparentemente semplice ma che nasconde diverse sottigliezze tecniche. Go, con la sua libreria standard ricca e ben progettata, offre tutti gli strumenti necessari per implementare verifiche DNS complete e robuste. Il package net gestisce in modo trasparente la complessità della risoluzione DNS, permettendoci di concentrarci sulla logica applicativa.

Quando si implementa uno strumento di verifica PTR per ambienti di produzione, è importante considerare aspetti come la concorrenza (utilizzando goroutine per verificare più domini in parallelo), il caching dei risultati per ridurre il carico sui server DNS, e l'integrazione con sistemi di monitoraggio per ricevere alert in caso di anomalie nella configurazione. La verifica FCrDNS, in particolare, dovrebbe essere parte integrante dei controlli di salute di qualsiasi server di posta elettronica o servizio che richieda autenticazione basata sull'indirizzo IP.

Il codice presentato in questo articolo costituisce una base solida che può essere estesa con funzionalità aggiuntive come l'analisi dei record SPF e DMARC, la verifica della consistenza tra più resolver DNS, o l'integrazione con API di terze parti per la verifica della reputazione degli indirizzi IP.