Gestire le query WHOIS in Go

Gestire le query WHOIS in Go

Go è particolarmente adatto a scrivere client di rete: la libreria standard include tutto ciò che serve per gestire connessioni TCP con scadenze precise, il pacchetto context permette di propagare annullamenti e timeout attraverso l'intera catena di chiamate, e le goroutine rendono naturale eseguire molte query in parallelo. In questo articolo costruiamo un pacchetto whois completo, con normalizzazione dei domini internazionalizzati, gestione dei referral, parsing delle risposte, interpretazione delle date, cache con deduplicazione delle richieste concorrenti e un servizio HTTP che espone il tutto.

Una nota sul contesto. Dal 28 gennaio 2025 ICANN non richiede più a registry e registrar dei domini generici (gTLD) di offrire WHOIS sulla porta 43, e per quei domini il riferimento è ora RDAP. WHOIS rimane comunque in uso: la maggior parte dei registry nazionali, compresi quelli di .it e .de, continua a offrirlo, così come alcuni registry generici, tra cui quello del .com. Il client dovrà quindi gestire con cura i casi in cui un server, in particolare quello di un registrar, non risponde più.

Il protocollo

Secondo la RFC 3912 una query WHOIS consiste nell'aprire una connessione TCP verso la porta 43 del server, inviare la query seguita da \r\n e leggere la risposta fino alla chiusura della connessione. Non ci sono intestazioni né lunghezze dichiarate, la codifica dei caratteri non è specificata e il formato è testo libero. Il percorso di ricerca parte di solito da whois.iana.org, che indica nel campo refer: il server del registry del TLD; la risposta del registry può a sua volta indicare, nel campo Registrar WHOIS Server:, il server del registrar.

Una prima query

Il pacchetto net rende la prima versione molto compatta. La funzione io.ReadAll() legge fino alla fine dello stream, cioè fino alla chiusura della connessione da parte del server.

package main

import (
	"fmt"
	"io"
	"log"
	"net"
)

func main() {
	conn, err := net.Dial("tcp", "whois.verisign-grs.com:43")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	// La query è il dominio seguito da CRLF
	if _, err := io.WriteString(conn, "domain example.com\r\n"); err != nil {
		log.Fatal(err)
	}

	data, err := io.ReadAll(conn)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))
}

La parola chiave domain è una particolarità del server di Verisign, che limita la ricerca ai soli domini escludendo i name server con lo stesso nome. Questo esempio funziona, ma non ha timeout, legge una quantità illimitata di dati e non valida nulla. Costruiamo ora il pacchetto vero e proprio.

Il pacchetto whois: errori e configurazione

Iniziamo dai tipi di base. Definiamo un errore sentinella per gli input non validi, verificabile con errors.Is(), e un tipo ServerError che avvolge gli errori di rete indicando il server coinvolto. Il client è una struttura configurabile con valori predefiniti ragionevoli.

// whois/client.go
package whois

import (
	"errors"
	"fmt"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"
)

const (
	Port       = "43"
	IANAServer = "whois.iana.org"
	maxHops    = 4
	defaultMax = 1 << 20
)

// ErrInvalidDomain indica un input che non è un nome di dominio valido.
var ErrInvalidDomain = errors.New("dominio non valido")

// ServerError descrive un errore avvenuto interrogando un server remoto.
type ServerError struct {
	Server string
	Err    error
}

func (e *ServerError) Error() string {
	return fmt.Sprintf("whois %s: %v", e.Server, e.Err)
}

func (e *ServerError) Unwrap() error {
	return e.Err
}

type Client struct {
	Timeout     time.Duration
	MaxBytes    int64
	MinInterval time.Duration
	TTL         time.Duration

	mu         sync.Mutex
	nextSlot   map[string]time.Time
	tldServers map[string]string
	cache      map[string]cacheEntry
	group      singleflight.Group
}

type cacheEntry struct {
	record    *Record
	expiresAt time.Time
}

func NewClient() *Client {
	return &Client{
		Timeout:     10 * time.Second,
		MaxBytes:    defaultMax,
		MinInterval: time.Second,
		TTL:         time.Hour,
		nextSlot:    make(map[string]time.Time),
		tldServers:  make(map[string]string),
		cache:       make(map[string]cacheEntry),
	}
}

Il pacchetto singleflight, parte dei moduli golang.org/x/sync, servirà per la deduplicazione delle richieste concorrenti. Le dipendenze esterne si limitano a due moduli del progetto Go:

go mod init example.com/whoisdemo
go get golang.org/x/net/idna golang.org/x/sync/singleflight

Normalizzare il dominio

La validazione dell'input è anche una misura di sicurezza: la query è una riga terminata da \r\n, e un input con caratteri di fine riga potrebbe iniettare comandi verso il server. I domini internazionalizzati vanno inoltre convertiti in Punycode: caffè.it diventa xn--caff-8oa.it. Il pacchetto golang.org/x/net/idna offre il profilo Lookup, che applica le regole UTS #46 previste per la risoluzione dei nomi e verifica la validità delle etichette.

// whois/domain.go
package whois

import (
	"fmt"
	"regexp"
	"strings"

	"golang.org/x/net/idna"
)

var asciiDomain = regexp.MustCompile(`^[a-z0-9.-]+$`)

// NormalizeDomain converte il dominio in forma ASCII minuscola e lo valida.
func NormalizeDomain(input string) (string, error) {
	// Rimuove spazi, maiuscole e l'eventuale punto finale
	domain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(input)), ".")

	ascii, err := idna.Lookup.ToASCII(domain)
	if err != nil || !asciiDomain.MatchString(ascii) || len(ascii) > 253 {
		return "", fmt.Errorf("%w: %q", ErrInvalidDomain, input)
	}

	labels := strings.Split(ascii, ".")
	if len(labels) < 2 {
		return "", fmt.Errorf("%w: %q", ErrInvalidDomain, input)
	}
	for _, label := range labels {
		if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
			return "", fmt.Errorf("%w: etichetta non valida in %q", ErrInvalidDomain, input)
		}
	}
	return ascii, nil
}

func topLevelDomain(domain string) string {
	return domain[strings.LastIndexByte(domain, '.')+1:]
}

L'uso di %w in fmt.Errorf() avvolge l'errore sentinella, così il chiamante può scrivere errors.Is(err, whois.ErrInvalidDomain) indipendentemente dal messaggio dettagliato.

La query con contesto, scadenze e limiti

In Go le scadenze sulle connessioni si impostano con SetDeadline(), che fissa un istante assoluto oltre il quale qualsiasi lettura o scrittura fallisce. Questo risolve in un colpo solo il problema della scadenza complessiva. Resta da gestire l'annullamento tramite context: se il chiamante annulla il contesto mentre siamo bloccati in lettura, la lettura non se ne accorgerebbe. La funzione context.AfterFunc(), disponibile da Go 1.21, registra una funzione da eseguire all'annullamento del contesto; la usiamo per spostare la scadenza al momento presente e sbloccare immediatamente la lettura.

Il limite alla dimensione si ottiene con io.LimitReader(), leggendo un byte oltre il massimo: se lo otteniamo, la risposta era troppo grande.

// whois/query.go
package whois

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"time"
	"unicode/utf8"
)

func (c *Client) queryServer(ctx context.Context, server, query string) (string, error) {
	dialer := net.Dialer{Timeout: c.Timeout}
	conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(server, Port))
	if err != nil {
		return "", &ServerError{Server: server, Err: err}
	}
	defer conn.Close()

	// Scadenza complessiva: la più vicina tra quella del contesto e quella del client
	deadline := time.Now().Add(3 * c.Timeout)
	if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
		deadline = d
	}
	if err := conn.SetDeadline(deadline); err != nil {
		return "", &ServerError{Server: server, Err: err}
	}

	// Se il contesto viene annullato, si sblocca subito qualsiasi operazione in corso
	stop := context.AfterFunc(ctx, func() {
		conn.SetDeadline(time.Now())
	})
	defer stop()

	if _, err := io.WriteString(conn, query+"\r\n"); err != nil {
		return "", &ServerError{Server: server, Err: err}
	}

	data, err := io.ReadAll(io.LimitReader(conn, c.MaxBytes+1))
	if err != nil {
		if ctxErr := ctx.Err(); ctxErr != nil {
			err = ctxErr
		}
		var netErr net.Error
		if errors.As(err, &netErr) && netErr.Timeout() {
			err = fmt.Errorf("scadenza superata: %w", err)
		}
		return "", &ServerError{Server: server, Err: err}
	}
	if int64(len(data)) > c.MaxBytes {
		return "", &ServerError{Server: server, Err: fmt.Errorf("risposta oltre %d byte", c.MaxBytes)}
	}

	return decode(data), nil
}

// decode interpreta i byte come UTF-8 se validi, altrimenti come Latin-1.
func decode(data []byte) string {
	if utf8.Valid(data) {
		return string(data)
	}
	// In Latin-1 ogni byte corrisponde al code point Unicode con lo stesso valore
	runes := make([]rune, len(data))
	for i, b := range data {
		runes[i] = rune(b)
	}
	return string(runes)
}

La conversione da Latin-1 sfrutta una proprietà elegante di questa codifica: i suoi 256 caratteri coincidono con i primi 256 code point di Unicode. Convertire ogni byte nella rune con lo stesso valore è quindi una decodifica corretta, senza bisogno di tabelle o di pacchetti aggiuntivi.

Limitazione della frequenza

Prima di scrivere la logica dei referral aggiungiamo il limitatore di frequenza per server. La tecnica è la prenotazione degli intervalli: sotto mutex si legge il primo istante libero per quel server, lo si prenota spostando in avanti il successivo e, rilasciato il mutex, si attende fino all'istante prenotato. L'attesa usa select per rispettare anche l'annullamento del contesto.

// whois/throttle.go
package whois

import (
	"context"
	"time"
)

func (c *Client) throttle(ctx context.Context, server string) error {
	c.mu.Lock()
	now := time.Now()
	slot := c.nextSlot[server]
	if slot.Before(now) {
		slot = now
	}
	// La prenotazione avviene sotto mutex, l'attesa fuori
	c.nextSlot[server] = slot.Add(c.MinInterval)
	c.mu.Unlock()

	wait := time.Until(slot)
	if wait <= 0 {
		return nil
	}

	timer := time.NewTimer(wait)
	defer timer.Stop()

	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

Referral e risoluzione

La risoluzione segue la catena dei server. Il client ricorda il server del registry associato a ciascun TLD per non interrogare IANA a ogni ricerca, ripulisce gli hostname indicati in forma di URL, evita i cicli e tollera il fallimento del solo registrar, conservando in quel caso la risposta del registry. Ogni passaggio della catena viene registrato in una struttura Hop, utile per la diagnosi.

// whois/lookup.go
package whois

import (
	"context"
	"fmt"
	"regexp"
	"strings"
)

type Hop struct {
	Server string `json:"server"`
	Error  string `json:"error,omitempty"`
	text   string
}

var (
	referralPatterns = []*regexp.Regexp{
		regexp.MustCompile(`(?im)^\s*refer:\s*(\S+)`),
		regexp.MustCompile(`(?im)^\s*whois:\s*(\S+)`),
		regexp.MustCompile(`(?im)^\s*Registrar WHOIS Server:\s*(\S+)`),
	}
	schemePrefix = regexp.MustCompile(`(?i)^[a-z]+://`)
	hostname     = regexp.MustCompile(`^[a-z0-9.-]+\.[a-z]{2,}$`)

	queryTemplates = map[string]string{
		"whois.verisign-grs.com": "domain %s",
	}
)

func buildQuery(server, domain string) string {
	if template, ok := queryTemplates[server]; ok {
		return fmt.Sprintf(template, domain)
	}
	return domain
}

func findReferral(text string) string {
	for _, pattern := range referralPatterns {
		match := pattern.FindStringSubmatch(text)
		if match == nil {
			continue
		}
		// Alcuni registrar indicano un URL: teniamo solo l'hostname
		host := schemePrefix.ReplaceAllString(match[1], "")
		if i := strings.IndexAny(host, "/:"); i >= 0 {
			host = host[:i]
		}
		host = strings.ToLower(host)
		if hostname.MatchString(host) {
			return host
		}
	}
	return ""
}

func (c *Client) query(ctx context.Context, server, query string) (string, error) {
	if err := c.throttle(ctx, server); err != nil {
		return "", &ServerError{Server: server, Err: err}
	}
	return c.queryServer(ctx, server, query)
}

func (c *Client) resolve(ctx context.Context, domain string) ([]Hop, string, error) {
	tld := topLevelDomain(domain)

	c.mu.Lock()
	server, known := c.tldServers[tld]
	c.mu.Unlock()
	if !known {
		server = IANAServer
	}

	var chain []Hop
	visited := make(map[string]bool)

	for server != "" && len(chain) < maxHops && !visited[server] {
		visited[server] = true

		text, err := c.query(ctx, server, buildQuery(server, domain))
		if err != nil {
			// Con una risposta del registry già ottenuta, un registrar muto non è fatale
			if hasRegistryAnswer(chain) {
				chain = append(chain, Hop{Server: server, Error: err.Error()})
				break
			}
			return chain, "", err
		}

		chain = append(chain, Hop{Server: server, text: text})
		next := findReferral(text)

		if server == IANAServer && next != "" {
			c.mu.Lock()
			c.tldServers[tld] = next
			c.mu.Unlock()
		}
		server = next
	}

	for i := len(chain) - 1; i >= 0; i-- {
		if chain[i].text != "" && chain[i].Server != IANAServer {
			return chain, chain[i].text, nil
		}
	}
	return chain, "", &ServerError{Server: IANAServer, Err: fmt.Errorf("nessun server per %s", domain)}
}

func hasRegistryAnswer(chain []Hop) bool {
	for _, hop := range chain {
		if hop.text != "" && hop.Server != IANAServer {
			return true
		}
	}
	return false
}

Il campo text della struttura Hop è volutamente non esportato: non finisce nella serializzazione JSON, dove riporteremo solo i nomi dei server e gli eventuali errori, ma resta disponibile all'interno del pacchetto.

Parsing della risposta e delle date

Il parser riconosce le due famiglie di formati più diffuse: righe Chiave: valore e blocchi con intestazione senza due punti seguita da righe rientrate, come nel registry italiano. Lo scorrimento riga per riga avviene con bufio.Scanner.

In questa versione andiamo un passo oltre rispetto alle stringhe e interpretiamo le date. Il pacchetto time di Go usa come modello di formato una data di riferimento, il 2 gennaio 2006 alle 15:04:05; elenchiamo i formati più comuni tra i registry e li proviamo in ordine, lasciando la data nulla se nessuno corrisponde.

// whois/parse.go
package whois

import (
	"bufio"
	"regexp"
	"strings"
	"time"
)

type Record struct {
	Domain      string     `json:"domain"`
	Available   bool       `json:"available"`
	Registrar   string     `json:"registrar,omitempty"`
	CreatedAt   *time.Time `json:"created_at,omitempty"`
	UpdatedAt   *time.Time `json:"updated_at,omitempty"`
	ExpiresAt   *time.Time `json:"expires_at,omitempty"`
	NameServers []string   `json:"name_servers"`
	Status      []string   `json:"status"`
	Chain       []Hop      `json:"chain"`
	Raw         string     `json:"raw,omitempty"`
}

var (
	commentPrefixes = []string{"%", "#", ">>>"}

	fieldAliases = map[string][]string{
		"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"},
	}

	notFoundPatterns = []*regexp.Regexp{
		regexp.MustCompile(`(?im)^no match for`),
		regexp.MustCompile(`(?im)^not found`),
		regexp.MustCompile(`(?im)^status:\s*(available|free)\b`),
		regexp.MustCompile(`(?i)no data found`),
		regexp.MustCompile(`(?i)no entries found`),
		regexp.MustCompile(`(?i)domain not found`),
	}

	dateLayouts = []string{
		time.RFC3339,
		"2006-01-02T15:04:05Z",
		"2006-01-02T15:04:05",
		"2006-01-02 15:04:05",
		"2006-01-02",
		"02-Jan-2006",
		"2006.01.02",
		"02.01.2006",
	}
)

func ParseFields(text string) map[string][]string {
	fields := make(map[string][]string)
	section := ""

	scanner := bufio.NewScanner(strings.NewReader(text))
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		// Una riga vuota chiude il blocco corrente
		if line == "" {
			section = ""
			continue
		}
		if hasCommentPrefix(line) {
			continue
		}

		key, value, found := strings.Cut(line, ":")
		if !found || key == "" {
			// Valore del blocco corrente oppure intestazione di un nuovo blocco
			if section != "" {
				fields[section] = append(fields[section], line)
			} else {
				section = strings.ToLower(line)
			}
			continue
		}

		key = strings.ToLower(strings.TrimSpace(key))
		value = strings.TrimSpace(value)
		if value == "" {
			section = key
			continue
		}
		if section != "" {
			key = section + "." + key
		}
		fields[key] = append(fields[key], value)
	}
	return fields
}

func hasCommentPrefix(line string) bool {
	for _, prefix := range commentPrefixes {
		if strings.HasPrefix(line, prefix) {
			return true
		}
	}
	return false
}

func IsAvailable(text string) bool {
	for _, pattern := range notFoundPatterns {
		if pattern.MatchString(text) {
			return true
		}
	}
	return false
}

func parseDate(value string) *time.Time {
	// Alcuni registry aggiungono testo dopo la data: si prova anche il primo token
	candidates := []string{value}
	if fields := strings.Fields(value); len(fields) > 0 && fields[0] != value {
		candidates = append(candidates, fields[0])
	}
	for _, candidate := range candidates {
		for _, layout := range dateLayouts {
			if t, err := time.Parse(layout, candidate); err == nil {
				utc := t.UTC()
				return &utc
			}
		}
	}
	return nil
}

func first(fields map[string][]string, name string) string {
	for _, alias := range fieldAliases[name] {
		if values := fields[alias]; len(values) > 0 {
			return values[0]
		}
	}
	return ""
}

func all(fields map[string][]string, name string) []string {
	seen := make(map[string]bool)
	result := []string{}
	for _, alias := range fieldAliases[name] {
		for _, value := range fields[alias] {
			// Teniamo solo il primo token: alcuni server aggiungono IP o URL
			token := strings.ToLower(strings.Fields(value)[0])
			if !seen[token] {
				seen[token] = true
				result = append(result, token)
			}
		}
	}
	return result
}

func toRecord(domain, text string) *Record {
	fields := ParseFields(text)
	return &Record{
		Domain:      domain,
		Available:   IsAvailable(text),
		Registrar:   first(fields, "registrar"),
		CreatedAt:   parseDate(first(fields, "created_at")),
		UpdatedAt:   parseDate(first(fields, "updated_at")),
		ExpiresAt:   parseDate(first(fields, "expires_at")),
		NameServers: all(fields, "name_servers"),
		Status:      all(fields, "status"),
		Raw:         text,
	}
}

La funzione strings.Cut() è l'equivalente idiomatico di una divisione alla prima occorrenza del separatore e restituisce un booleano che indica se il separatore è stato trovato. Le date sono puntatori, così l'assenza di una data si traduce in un campo omesso nel JSON invece che in un valore zero fuorviante come il primo gennaio dell'anno 1. Il rilevamento della disponibilità resta un'euristica sul testo grezzo: solo il registrar, al momento della registrazione, può confermare che un dominio è libero.

Cache e deduplicazione con singleflight

Il metodo pubblico Lookup() combina normalizzazione, cache e risoluzione. Il pacchetto singleflight garantisce che, se più goroutine chiedono contemporaneamente lo stesso dominio, la risoluzione venga eseguita una sola volta e il risultato condiviso tra tutte.

// whois/lookup_public.go
package whois

import (
	"context"
	"sync"
	"time"
)

func (c *Client) Lookup(ctx context.Context, input string, fresh bool) (*Record, error) {
	domain, err := NormalizeDomain(input)
	if err != nil {
		return nil, err
	}

	if !fresh {
		c.mu.Lock()
		entry, ok := c.cache[domain]
		c.mu.Unlock()
		if ok && time.Now().Before(entry.expiresAt) {
			return entry.record, nil
		}
	}

	// Le richieste concorrenti per lo stesso dominio condividono un'unica risoluzione
	value, err, _ := c.group.Do(domain, func() (any, error) {
		chain, text, err := c.resolve(ctx, domain)
		if err != nil {
			return nil, err
		}
		record := toRecord(domain, text)
		record.Chain = chain

		c.mu.Lock()
		c.cache[domain] = cacheEntry{record: record, expiresAt: time.Now().Add(c.TTL)}
		c.mu.Unlock()
		return record, nil
	})
	if err != nil {
		return nil, err
	}
	return value.(*Record), nil
}

// LookupMany interroga più domini in parallelo con un limite di concorrenza.
func (c *Client) LookupMany(ctx context.Context, inputs []string, concurrency int) map[string]Result {
	results := make(map[string]Result, len(inputs))
	var mu sync.Mutex
	var wg sync.WaitGroup
	semaphore := make(chan struct{}, concurrency)

	for _, input := range inputs {
		wg.Add(1)
		go func() {
			defer wg.Done()
			semaphore <- struct{}{}
			defer func() { <-semaphore }()

			record, err := c.Lookup(ctx, input, false)
			mu.Lock()
			results[input] = Result{Record: record, Err: err}
			mu.Unlock()
		}()
	}
	wg.Wait()
	return results
}

type Result struct {
	Record *Record
	Err    error
}

C'è una sottigliezza nell'uso di singleflight con i contesti: la funzione passata a Do() usa il contesto della prima goroutine che l'ha avviata. Se quel chiamante annulla la propria richiesta, la risoluzione condivisa fallisce anche per gli altri. Per un servizio in cui gli annullamenti sono frequenti si può usare context.WithoutCancel() con un timeout proprio all'interno della funzione, separando così il destino della risoluzione da quello del singolo chiamante.

In LookupMany() il canale bufferizzato semaphore limita il numero di goroutine attive contemporaneamente. Da Go 1.22 la variabile del ciclo for è nuova a ogni iterazione, quindi la closure cattura correttamente il valore di input senza copie esplicite.

Un servizio HTTP

Dal Go 1.22 il router della libreria standard supporta metodi e parametri nei pattern, e per un servizio di questo tipo non serve altro. Il gestore distingue gli errori di input da quelli remoti con errors.Is() ed errors.As(), applica un timeout per richiesta tramite il contesto e omette il testo originale a meno che non sia richiesto esplicitamente.

// cmd/whoisd/main.go
package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"time"

	"example.com/whoisdemo/whois"
)

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}

func main() {
	client := whois.NewClient()
	client.MinInterval = 1500 * time.Millisecond

	mux := http.NewServeMux()
	mux.HandleFunc("GET /whois/{domain}", func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
		defer cancel()

		query := r.URL.Query()
		record, err := client.Lookup(ctx, r.PathValue("domain"), query.Has("fresh"))

		var serverErr *whois.ServerError
		switch {
		case errors.Is(err, whois.ErrInvalidDomain):
			writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
			return
		case errors.As(err, &serverErr):
			writeJSON(w, http.StatusBadGateway, map[string]string{
				"error":  serverErr.Err.Error(),
				"server": serverErr.Server,
			})
			return
		case err != nil:
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
			return
		}

		// Si copia il record per non modificare quello condiviso in cache
		response := *record
		if !query.Has("raw") {
			response.Raw = ""
		}
		writeJSON(w, http.StatusOK, response)
	})

	server := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Println("servizio WHOIS in ascolto su :8080")
	log.Fatal(server.ListenAndServe())
}

La copia del record prima di azzerare il campo Raw è essenziale: il puntatore restituito da Lookup() è lo stesso conservato nella cache e condiviso tra tutte le goroutine, e modificarlo direttamente rimuoverebbe il testo originale per tutte le richieste successive, oltre a costituire una corsa sui dati. Lo stesso servizio si integra senza difficoltà in un'applicazione Gin, sostituendo il gestore con una funzione che riceve un *gin.Context.

go run ./cmd/whoisd
curl http://localhost:8080/whois/example.com
curl 'http://localhost:8080/whois/example.it?raw'

Testare il parser

I test tabellari sono l'idioma naturale in Go per verificare il parser su risposte di registry diversi.

// whois/parse_test.go
package whois

import "testing"

func TestToRecord(t *testing.T) {
	tests := []struct {
		name        string
		text        string
		available   bool
		registrar   string
		nameServers int
	}{
		{
			name:        "formato chiave valore",
			text:        "Domain Name: EXAMPLE.COM\nRegistrar: Example Inc.\nName Server: A.EXAMPLE.NET\nName Server: B.EXAMPLE.NET\n",
			registrar:   "Example Inc.",
			nameServers: 2,
		},
		{
			name:        "formato a blocchi",
			text:        "Domain: example.it\nStatus: ok\n\nRegistrar\n  Organization: Registrar S.r.l.\n\nNameservers\n  dns1.example.it\n  dns2.example.it\n",
			registrar:   "Registrar S.r.l.",
			nameServers: 2,
		},
		{
			name:      "dominio libero",
			text:      "Domain: libero.it\nStatus: AVAILABLE\n",
			available: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			record := toRecord("test", tt.text)
			if record.Available != tt.available {
				t.Errorf("available = %v, atteso %v", record.Available, tt.available)
			}
			if record.Registrar != tt.registrar {
				t.Errorf("registrar = %q, atteso %q", record.Registrar, tt.registrar)
			}
			if len(record.NameServers) != tt.nameServers {
				t.Errorf("name server = %d, attesi %d", len(record.NameServers), tt.nameServers)
			}
		})
	}
}

Considerazioni finali

Il pacchetto che abbiamo scritto gestisce le situazioni più comuni, ma va collocato nel contesto attuale. Per i domini generici RDAP è il protocollo di riferimento: usa HTTPS, restituisce JSON strutturato e individua il server corretto tramite il registro di bootstrap pubblicato da IANA. Con net/http ed encoding/json aggiungere un percorso RDAP è semplice, e un client moderno dovrebbe tentarlo per primo, ricorrendo a WHOIS dove RDAP non è disponibile. Dopo il GDPR, inoltre, i dati personali degli intestatari sono in gran parte oscurati, e diciture come REDACTED FOR PRIVACY sono la norma.

Infine, la cache e il limitatore che abbiamo scritto vivono nella memoria del processo. Se il servizio gira su più istanze, entrambi vanno spostati su un archivio condiviso come Redis, altrimenti ogni istanza rispetterà i limiti per conto proprio e i server WHOIS vedranno un traffico moltiplicato. Rispettare i limiti di frequenza e le condizioni d'uso, che vietano generalmente l'interrogazione massiva, non è solo una questione di cortesia: è ciò che evita al client di essere bloccato.