Gestire le query WHOIS in Java
Java offre da sempre un'API per i socket solida e prevedibile, e le versioni recenti del linguaggio hanno aggiunto strumenti che rendono un client di rete molto più semplice da scrivere: i record per i dati immutabili, i thread virtuali per eseguire migliaia di operazioni bloccanti senza consumare thread di sistema, i metodi di lettura con limite sugli stream. In questo articolo costruiamo con Java 21 un client WHOIS completo, senza dipendenze esterne, che comprende normalizzazione dei domini internazionalizzati, gestione dei referral, parsing delle risposte, cache con deduplicazione, limitazione della frequenza e un piccolo server HTTP.
Prima di cominciare, un chiarimento sullo stato del protocollo. Il 28 gennaio 2025 è decaduto per registry e registrar dei domini generici (gTLD) l'obbligo contrattuale verso ICANN di offrire WHOIS sulla porta 43, e il riferimento per quei domini è ora RDAP. WHOIS resta comunque molto diffuso: lo usano gran parte dei registry nazionali, compresi quelli di .it e .de, e diversi registry generici, tra cui quello del .com, lo mantengono attivo. Il client deve quindi essere preparato a server che rispondono e a server che hanno smesso di farlo.
Il protocollo
La RFC 3912 definisce un protocollo minimale: connessione TCP alla porta 43, invio della query terminata da \r\n, lettura della risposta fino alla chiusura della connessione da parte del server. Non c'è un formato strutturato, non c'è una lunghezza dichiarata e non c'è una codifica dichiarata. Ne derivano i requisiti che guideranno il codice: timeout, limite alla dimensione della risposta, decodifica con ripiego, validazione dell'input, gestione dei referral e parsing tollerante.
Per individuare il server da interrogare si parte da whois.iana.org, che per qualunque dominio restituisce il record del TLD con il campo refer:. La risposta del registry, a sua volta, può indicare nel campo Registrar WHOIS Server: il server del registrar.
Una prima query
La prima versione usa le classi di java.net e java.io nel modo più diretto possibile.
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class FirstQuery {
public static void main(String[] args) throws Exception {
try (Socket socket = new Socket("whois.verisign-grs.com", 43)) {
OutputStream out = socket.getOutputStream();
// La query è il dominio seguito da CRLF
out.write("domain example.com\r\n".getBytes(StandardCharsets.US_ASCII));
out.flush();
InputStream in = socket.getInputStream();
byte[] data = in.readAllBytes();
System.out.println(new String(data, StandardCharsets.UTF_8));
}
}
}
Il costrutto try con risorse chiude il socket in ogni caso. Il metodo readAllBytes() legge fino alla fine dello stream, che per WHOIS coincide con la chiusura della connessione. La parola chiave domain è richiesta dal server di Verisign per limitare la ricerca ai domini. Mancano però timeout, limiti e validazione.
Eccezioni e tipi di base
Definiamo due eccezioni: una per gli input non validi e una per gli errori dei server remoti, che porta con sé il nome del server. Le rendiamo eccezioni non controllate, estendendo RuntimeException, per non appesantire le firme dei metodi, ma con una gerarchia comune che permette di intercettarle insieme quando serve.
package whois;
public class WhoisException extends RuntimeException {
public WhoisException(String message, Throwable cause) {
super(message, cause);
}
public WhoisException(String message) {
super(message);
}
}
package whois;
public final class InvalidDomainException extends WhoisException {
public InvalidDomainException(String input) {
super("Dominio non valido: " + input);
}
}
package whois;
public final class WhoisServerException extends WhoisException {
private final String server;
public WhoisServerException(String server, String message, Throwable cause) {
super(message, cause);
this.server = server;
}
public WhoisServerException(String server, String message) {
this(server, message, null);
}
public String server() {
return server;
}
}
Normalizzare il dominio
La validazione protegge da un rischio concreto: la query è una riga terminata da \r\n, e un input con caratteri di fine riga permetterebbe di iniettare comandi aggiuntivi verso il server. Inoltre i domini internazionalizzati devono essere convertiti in Punycode: caffè.it diventa xn--caff-8oa.it. Java offre la classe java.net.IDN, che implementa lo standard IDNA 2003. Per la maggior parte dei domini il risultato coincide con quello di IDNA 2008, ma su alcuni caratteri, come la ß tedesca, le due versioni differiscono; se serve la massima fedeltà si può ricorrere alla classe IDNA della libreria ICU4J.
package whois;
import java.net.IDN;
import java.util.Locale;
import java.util.regex.Pattern;
public final class Domains {
private static final Pattern ASCII_DOMAIN = Pattern.compile("^[a-z0-9.-]+$");
private Domains() {
}
public static String normalize(String input) {
if (input == null) {
throw new InvalidDomainException("null");
}
// Rimuove spazi, maiuscole e l'eventuale punto finale
String domain = input.strip().toLowerCase(Locale.ROOT);
if (domain.endsWith(".")) {
domain = domain.substring(0, domain.length() - 1);
}
String ascii;
try {
ascii = IDN.toASCII(domain, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
} catch (IllegalArgumentException e) {
throw new InvalidDomainException(input);
}
if (!ASCII_DOMAIN.matcher(ascii).matches() || ascii.length() > 253) {
throw new InvalidDomainException(input);
}
String[] labels = ascii.split("\\.", -1);
if (labels.length < 2) {
throw new InvalidDomainException(input);
}
for (String label : labels) {
if (label.isEmpty() || label.length() > 63 || label.startsWith("-") || label.endsWith("-")) {
throw new InvalidDomainException(input);
}
}
return ascii;
}
public static String topLevelDomain(String domain) {
return domain.substring(domain.lastIndexOf('.') + 1);
}
}
Due dettagli: Locale.ROOT in toLowerCase() evita sorprese con le impostazioni internazionali, come la celebre I turca che diventerebbe una ı senza punto; il limite -1 passato a split() conserva le etichette vuote finali, che altrimenti verrebbero scartate silenziosamente e renderebbero inefficace il controllo.
La query di rete
La classe Socket offre due timeout distinti: quello passato a connect() limita la durata della connessione, mentre setSoTimeout() limita ogni singola operazione di lettura. Nessuno dei due, però, limita la durata complessiva: un server che invia un byte ogni pochi secondi può tenere occupata la connessione a lungo. Gestiremo la scadenza complessiva più avanti, sfruttando i thread virtuali.
Per il limite alla dimensione usiamo readNBytes(), che legge al massimo il numero di byte indicato: chiedendone uno in più del limite, sappiamo che la risposta era troppo grande se li otteniamo tutti. La decodifica usa un CharsetDecoder configurato per segnalare gli errori invece di sostituire i caratteri non validi, con ripiego su Latin-1.
package whois;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
public final class WhoisTransport {
public static final int PORT = 43;
private final Duration timeout;
private final int maxBytes;
public WhoisTransport(Duration timeout, int maxBytes) {
this.timeout = timeout;
this.maxBytes = maxBytes;
}
public WhoisTransport() {
this(Duration.ofSeconds(10), 1024 * 1024);
}
public String query(String server, String query) {
int millis = Math.toIntExact(timeout.toMillis());
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(server, PORT), millis);
socket.setSoTimeout(millis);
OutputStream out = socket.getOutputStream();
out.write((query + "\r\n").getBytes(StandardCharsets.US_ASCII));
out.flush();
InputStream in = socket.getInputStream();
byte[] data = in.readNBytes(maxBytes + 1);
if (data.length > maxBytes) {
throw new WhoisServerException(server, "Risposta oltre il limite di " + maxBytes + " byte");
}
return decode(data);
} catch (SocketTimeoutException e) {
throw new WhoisServerException(server, "Nessuna risposta entro " + timeout.toSeconds() + " secondi", e);
} catch (IOException e) {
throw new WhoisServerException(server, "Errore di rete: " + e.getMessage(), e);
}
}
static String decode(byte[] data) {
try {
// UTF-8 rigoroso: un errore di codifica solleva un'eccezione
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(data))
.toString();
} catch (CharacterCodingException e) {
// Latin-1 decodifica qualsiasi sequenza di byte
return new String(data, StandardCharsets.ISO_8859_1);
}
}
}
La conversione con new String(data, StandardCharsets.UTF_8), usata nel primo esempio, non avrebbe mai segnalato un errore: avrebbe semplicemente sostituito i byte non validi con il carattere di rimpiazzo, corrompendo in silenzio le risposte dei server che usano Latin-1. Il decoder esplicito con CodingErrorAction.REPORT ci permette invece di accorgercene e di ripiegare.
Il parser e il record
Le risposte WHOIS si dividono in due famiglie principali: righe Chiave: valore e blocchi con un'intestazione senza due punti seguita da righe rientrate, come nel registry italiano. Il parser le gestisce entrambe e produce una mappa ordinata di liste. Il risultato finale è un record, il costrutto di Java pensato esattamente per dati immutabili come questi.
package whois;
import java.util.List;
public record WhoisRecord(
String domain,
boolean available,
String registrar,
String createdAt,
String updatedAt,
String expiresAt,
List<String> nameServers,
List<String> status,
List<String> servers,
String raw) {
public WhoisRecord {
// Copie difensive: le liste del record sono immutabili
nameServers = List.copyOf(nameServers);
status = List.copyOf(status);
servers = List.copyOf(servers);
}
}
package whois;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class WhoisParser {
private static final List<String> COMMENT_PREFIXES = List.of("%", "#", ">>>");
private static final Map<String, List<String>> FIELD_ALIASES = Map.of(
"registrar", List.of("registrar", "registrar.organization", "registrar.name", "sponsoring registrar"),
"createdAt", List.of("creation date", "created", "created on", "registered on", "registration time"),
"updatedAt", List.of("updated date", "last update", "last updated", "changed", "modified"),
"expiresAt", List.of("registry expiry date", "registrar registration expiration date",
"expiry date", "expire date", "expire", "expires", "expiration time"),
"nameServers", List.of("name server", "nameservers", "nserver"),
"status", List.of("domain status", "status", "state"));
private static final List<Pattern> NOT_FOUND_PATTERNS = List.of(
Pattern.compile("^no match for", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
Pattern.compile("^not found", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
Pattern.compile("^status:\\s*(available|free)\\b", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
Pattern.compile("no data found", Pattern.CASE_INSENSITIVE),
Pattern.compile("no entries found", Pattern.CASE_INSENSITIVE),
Pattern.compile("domain not found", Pattern.CASE_INSENSITIVE));
private static final List<Pattern> REFERRAL_PATTERNS = List.of(
Pattern.compile("^\\s*refer:\\s*(\\S+)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
Pattern.compile("^\\s*whois:\\s*(\\S+)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE),
Pattern.compile("^\\s*Registrar WHOIS Server:\\s*(\\S+)", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE));
private static final Pattern HOSTNAME = Pattern.compile("^[a-z0-9.-]+\\.[a-z]{2,}$");
public Map<String, List<String>> fields(String text) {
Map<String, List<String>> fields = new LinkedHashMap<>();
String section = null;
for (String rawLine : text.split("\\R")) {
String line = rawLine.strip();
// Una riga vuota chiude il blocco corrente
if (line.isEmpty()) {
section = null;
continue;
}
if (COMMENT_PREFIXES.stream().anyMatch(line::startsWith)) {
continue;
}
int separator = line.indexOf(':');
if (separator <= 0) {
// Valore del blocco corrente oppure intestazione di un nuovo blocco
if (section != null) {
fields.computeIfAbsent(section, k -> new ArrayList<>()).add(line);
} else {
section = line.toLowerCase(Locale.ROOT);
}
continue;
}
String key = line.substring(0, separator).strip().toLowerCase(Locale.ROOT);
String value = line.substring(separator + 1).strip();
if (value.isEmpty()) {
section = key;
continue;
}
String fullKey = section != null ? section + "." + key : key;
fields.computeIfAbsent(fullKey, k -> new ArrayList<>()).add(value);
}
return fields;
}
public boolean isAvailable(String text) {
return NOT_FOUND_PATTERNS.stream().anyMatch(pattern -> pattern.matcher(text).find());
}
public Optional<String> referral(String text) {
for (Pattern pattern : REFERRAL_PATTERNS) {
Matcher matcher = pattern.matcher(text);
if (matcher.find()) {
// Alcuni registrar indicano un URL: teniamo solo l'hostname
String host = matcher.group(1)
.replaceFirst("(?i)^[a-z]+://", "")
.replaceFirst("[/:].*$", "")
.toLowerCase(Locale.ROOT);
if (HOSTNAME.matcher(host).matches()) {
return Optional.of(host);
}
}
}
return Optional.empty();
}
public WhoisRecord record(String domain, String text, List<String> servers) {
Map<String, List<String>> fields = fields(text);
return new WhoisRecord(
domain,
isAvailable(text),
first(fields, "registrar"),
first(fields, "createdAt"),
first(fields, "updatedAt"),
first(fields, "expiresAt"),
all(fields, "nameServers"),
all(fields, "status"),
servers,
text);
}
private String first(Map<String, List<String>> fields, String name) {
for (String alias : FIELD_ALIASES.get(name)) {
List<String> values = fields.get(alias);
if (values != null && !values.isEmpty()) {
return values.getFirst();
}
}
return null;
}
private List<String> all(Map<String, List<String>> fields, String name) {
LinkedHashSet<String> result = new LinkedHashSet<>();
for (String alias : FIELD_ALIASES.get(name)) {
for (String value : fields.getOrDefault(alias, List.of())) {
// Teniamo solo il primo token: alcuni server aggiungono IP o URL
result.add(value.split("\\s+")[0].toLowerCase(Locale.ROOT));
}
}
return List.copyOf(result);
}
}
L'espressione \R usata in split() riconosce qualsiasi interruzione di riga, sia \n sia \r\n. Il metodo getFirst() è una delle novità di Java 21 introdotte con le collezioni sequenziali. Come in ogni client WHOIS, il controllo di disponibilità è un'euristica sul testo grezzo: la conferma definitiva arriva solo dal registrar al momento della registrazione.
Il client: referral, cache e limitazione
Il client coordina tutto. La risoluzione parte da whois.iana.org o dal server già noto per il TLD, segue i referral evitando i cicli e tollera il fallimento del solo registrar. Il limitatore usa la tecnica della prenotazione degli intervalli: in un blocco sincronizzato si legge e si aggiorna il primo istante libero per il server, e l'attesa avviene fuori dal blocco.
La deduplicazione delle richieste concorrenti sfrutta ConcurrentHashMap e CompletableFuture: il metodo computeIfAbsent() garantisce che una sola richiesta per dominio crei il future, mentre le altre ricevono lo stesso. La risoluzione vera e propria viene eseguita fuori da computeIfAbsent(), perché eseguire operazioni lunghe al suo interno bloccherebbe parti della mappa.
package whois;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
public final class WhoisClient {
public static final String IANA_SERVER = "whois.iana.org";
private static final int MAX_HOPS = 4;
private static final Map<String, String> QUERY_TEMPLATES = Map.of(
"whois.verisign-grs.com", "domain %s");
private record CacheEntry(WhoisRecord record, Instant expiresAt) {
}
private record Hop(String server, String text) {
}
private final WhoisTransport transport;
private final WhoisParser parser = new WhoisParser();
private final Duration ttl;
private final Duration minInterval;
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
private final Map<String, CompletableFuture<WhoisRecord>> inFlight = new ConcurrentHashMap<>();
private final Map<String, String> tldServers = new ConcurrentHashMap<>();
private final Map<String, Instant> nextSlot = new HashMap<>();
public WhoisClient(WhoisTransport transport, Duration ttl, Duration minInterval) {
this.transport = transport;
this.ttl = ttl;
this.minInterval = minInterval;
}
public WhoisClient() {
this(new WhoisTransport(), Duration.ofHours(1), Duration.ofSeconds(1));
}
public WhoisRecord lookup(String input, boolean fresh) {
String domain = Domains.normalize(input);
CacheEntry cached = cache.get(domain);
if (!fresh && cached != null && cached.expiresAt().isAfter(Instant.now())) {
return cached.record();
}
CompletableFuture<WhoisRecord> created = new CompletableFuture<>();
CompletableFuture<WhoisRecord> existing = inFlight.putIfAbsent(domain, created);
if (existing != null) {
// Un'altra richiesta sta già risolvendo questo dominio
return join(existing);
}
try {
WhoisRecord record = resolve(domain);
cache.put(domain, new CacheEntry(record, Instant.now().plus(ttl)));
created.complete(record);
return record;
} catch (RuntimeException e) {
created.completeExceptionally(e);
throw e;
} finally {
inFlight.remove(domain, created);
}
}
private static WhoisRecord join(CompletableFuture<WhoisRecord> future) {
try {
return future.join();
} catch (CompletionException e) {
if (e.getCause() instanceof RuntimeException cause) {
throw cause;
}
throw e;
}
}
private WhoisRecord resolve(String domain) {
String tld = Domains.topLevelDomain(domain);
String server = tldServers.getOrDefault(tld, IANA_SERVER);
List<Hop> chain = new ArrayList<>();
List<String> servers = new ArrayList<>();
Set<String> visited = new HashSet<>();
while (server != null && chain.size() < MAX_HOPS && visited.add(server)) {
servers.add(server);
String text;
try {
text = query(server, buildQuery(server, domain));
} catch (WhoisServerException e) {
// Con una risposta del registry già ottenuta, un registrar muto non è fatale
if (chain.stream().anyMatch(hop -> !hop.server().equals(IANA_SERVER))) {
break;
}
throw e;
}
chain.add(new Hop(server, text));
Optional<String> next = parser.referral(text);
if (server.equals(IANA_SERVER)) {
next.ifPresent(host -> tldServers.put(tld, host));
}
server = next.orElse(null);
}
Hop answer = chain.reversed().stream()
.filter(hop -> !hop.server().equals(IANA_SERVER))
.findFirst()
.orElseThrow(() -> new WhoisServerException(IANA_SERVER, "Nessun server WHOIS per " + domain));
return parser.record(domain, answer.text(), servers);
}
private String buildQuery(String server, String domain) {
return QUERY_TEMPLATES.getOrDefault(server, "%s").formatted(domain);
}
private String query(String server, String query) {
throttle(server);
return transport.query(server, query);
}
private void throttle(String server) {
Instant slot;
synchronized (nextSlot) {
Instant now = Instant.now();
Instant reserved = nextSlot.getOrDefault(server, now);
slot = reserved.isBefore(now) ? now : reserved;
// Si prenota l'intervallo sotto lock, l'attesa avviene fuori
nextSlot.put(server, slot.plus(minInterval));
}
Duration wait = Duration.between(Instant.now(), slot);
if (!wait.isNegative() && !wait.isZero()) {
try {
Thread.sleep(wait);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new WhoisServerException(server, "Attesa interrotta", e);
}
}
}
}
Il metodo inFlight.remove(domain, created) rimuove la voce solo se è ancora associata al future creato da questa chiamata, evitando di cancellare per errore quello di una richiesta successiva. Il metodo reversed(), anch'esso introdotto con le collezioni sequenziali di Java 21, permette di cercare l'ultima risposta utile della catena con uno stream.
Query parallele con i thread virtuali
Il client è sincrono: ogni chiamata a lookup() blocca il thread finché la risposta non arriva. Con i thread di sistema tradizionali questo limiterebbe la concorrenza, ma con i thread virtuali di Java 21 il costo di un thread bloccato in attesa della rete è trascurabile. Possiamo quindi scrivere codice bloccante, semplice da leggere, ed eseguirne migliaia di istanze in parallelo.
I thread virtuali risolvono anche il problema della scadenza complessiva. Secondo la specifica dei thread virtuali, le operazioni bloccanti sui socket di java.net eseguite in un thread virtuale sono interrompibili: interrompere il thread chiude il socket e sblocca la lettura con un'eccezione. Il metodo invokeAll() con timeout annulla, e quindi interrompe, i task non completati entro il tempo indicato.
package whois;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public final class BulkLookup {
public sealed interface Outcome permits Success, Failure {
}
public record Success(WhoisRecord record) implements Outcome {
}
public record Failure(String message) implements Outcome {
}
private final WhoisClient client;
private final Semaphore permits;
public BulkLookup(WhoisClient client, int concurrency) {
this.client = client;
this.permits = new Semaphore(concurrency);
}
public Map<String, Outcome> run(List<String> domains, Duration deadline) throws InterruptedException {
List<Callable<WhoisRecord>> tasks = domains.stream()
.<Callable<WhoisRecord>>map(domain -> () -> {
// Il semaforo limita le connessioni contemporanee
permits.acquire();
try {
return client.lookup(domain, false);
} finally {
permits.release();
}
})
.toList();
Map<String, Outcome> results = new LinkedHashMap<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<WhoisRecord>> futures = executor.invokeAll(tasks, deadline.toMillis(), TimeUnit.MILLISECONDS);
for (int i = 0; i < domains.size(); i++) {
Future<WhoisRecord> future = futures.get(i);
Outcome outcome;
try {
outcome = new Success(future.get());
} catch (CancellationException e) {
outcome = new Failure("Scadenza complessiva superata");
} catch (ExecutionException e) {
outcome = new Failure(e.getCause().getMessage());
}
results.put(domains.get(i), outcome);
}
}
return results;
}
}
Il tipo Outcome è un'interfaccia sigillata con due sole implementazioni, entrambe record. Chi consuma il risultato può usare il pattern matching nello switch sapendo che il compilatore verificherà la copertura di tutti i casi:
Map<String, BulkLookup.Outcome> results = new BulkLookup(new WhoisClient(), 8)
.run(List.of("example.com", "example.org", "caffè.it"), Duration.ofSeconds(60));
results.forEach((domain, outcome) -> {
String line = switch (outcome) {
case BulkLookup.Success(WhoisRecord record) ->
domain + ": " + (record.available() ? "libero" : "registrato fino a " + record.expiresAt());
case BulkLookup.Failure(String message) -> domain + ": errore, " + message;
};
System.out.println(line);
});
Il costrutto try con risorse sull'ExecutorService, possibile da Java 19, attende la terminazione di tutti i task alla chiusura del blocco, quindi non restano thread orfani anche in caso di eccezioni.
Un server HTTP minimale
Il JDK include un server HTTP leggero nel package com.sun.net.httpserver, adatto a servizi interni e prototipi. Configurandolo con un esecutore di thread virtuali, ogni richiesta viene servita da un thread virtuale dedicato, e il client WHOIS sincrono si integra senza alcun adattamento. Per non introdurre dipendenze generiamo il JSON a mano con una piccola funzione di escape; in un progetto reale si userebbe una libreria come Jackson.
package whois;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
public final class WhoisServer {
private static final WhoisClient CLIENT = new WhoisClient();
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/whois/", WhoisServer::handle);
server.start();
System.out.println("Servizio WHOIS in ascolto su http://localhost:8080");
}
private static void handle(HttpExchange exchange) throws IOException {
if (!exchange.getRequestMethod().equals("GET")) {
send(exchange, 405, "{\"error\":\"Metodo non consentito\"}");
return;
}
String path = exchange.getRequestURI().getRawPath();
String domain = URLDecoder.decode(path.substring("/whois/".length()), StandardCharsets.UTF_8);
try {
WhoisRecord record = CLIENT.lookup(domain, false);
send(exchange, 200, toJson(record));
} catch (InvalidDomainException e) {
send(exchange, 422, "{\"error\":" + quote(e.getMessage()) + "}");
} catch (WhoisServerException e) {
send(exchange, 502, "{\"error\":" + quote(e.getMessage()) + ",\"server\":" + quote(e.server()) + "}");
}
}
private static void send(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
}
private static String toJson(WhoisRecord r) {
return "{\"domain\":" + quote(r.domain())
+ ",\"available\":" + r.available()
+ ",\"registrar\":" + quote(r.registrar())
+ ",\"created_at\":" + quote(r.createdAt())
+ ",\"updated_at\":" + quote(r.updatedAt())
+ ",\"expires_at\":" + quote(r.expiresAt())
+ ",\"name_servers\":" + array(r.nameServers())
+ ",\"status\":" + array(r.status())
+ ",\"servers\":" + array(r.servers()) + "}";
}
private static String array(List<String> values) {
return values.stream().map(WhoisServer::quote).collect(Collectors.joining(",", "[", "]"));
}
private static String quote(String value) {
if (value == null) {
return "null";
}
StringBuilder sb = new StringBuilder("\"");
for (char c : value.toCharArray()) {
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
// I caratteri di controllo vanno sempre codificati
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
return sb.append('"').toString();
}
}
Compilazione ed esecuzione non richiedono strumenti di build:
javac -d out $(find src -name '*.java')
java -cp out whois.WhoisServer
curl http://localhost:8080/whois/example.com
Considerazioni finali
Il client che abbiamo costruito copre i casi d'uso tipici, ma va inserito nel contesto attuale. Per i domini generici RDAP è il protocollo di riferimento: funziona su HTTPS, restituisce JSON strutturato e prevede un registro di bootstrap pubblicato da IANA per individuare il server corretto. Con java.net.http.HttpClient, incluso nel JDK, aggiungere un percorso RDAP è semplice, e un client moderno dovrebbe tentarlo per primo, usando WHOIS per i TLD che non lo supportano.
Dopo il GDPR molti dati degli intestatari sono oscurati, e diciture come REDACTED FOR PRIVACY vanno considerate normali. La cache e il limitatore, infine, vivono nella memoria della JVM: se il servizio gira su più istanze vanno spostati su un archivio condiviso, altrimenti ogni istanza rispetterà i limiti di frequenza per conto proprio. Le condizioni d'uso dei server WHOIS vietano in genere l'interrogazione massiva, e rispettarle è la condizione perché il client continui a funzionare nel tempo.