Usare le API di Cloudflare per la gestione DNS con Java
Cloudflare mette a disposizione un'API REST completa (la versione 4) che permette di gestire in modo programmatico praticamente tutto ciò che si può fare dalla dashboard, a partire dalle zone e dai record DNS. Automatizzare la gestione DNS è utile in molti scenari: provisioning di nuovi ambienti, aggiornamento dinamico dell'indirizzo IP di un server domestico, rotazione di record per deployment blue/green, verifica dei domini per l'emissione di certificati. In questo articolo costruiremo un client Java completo, basato esclusivamente su java.net.http.HttpClient e su Jackson per la serializzazione JSON, senza SDK di terze parti.
Prerequisiti e creazione del token
Cloudflare supporta due metodi di autenticazione: la vecchia Global API Key (inviata con le intestazioni X-Auth-Email e X-Auth-Key) e gli API Token. La Global API Key concede accesso illimitato all'intero account e non dovrebbe mai essere usata in un'applicazione: gli API Token, invece, possono essere limitati a specifici permessi, a specifiche zone e persino a specifici indirizzi IP di origine.
Dalla dashboard, nella sezione My Profile → API Tokens, si crea un nuovo token partendo dal template Edit zone DNS, oppure definendo manualmente questi permessi:
Zone → Zone → Read, necessario per risolvere il nome di dominio nel corrispondente identificativo di zona;Zone → DNS → Edit, necessario per leggere, creare, modificare ed eliminare i record;- una restrizione Zone Resources limitata alle sole zone che l'applicazione deve gestire.
Il token viene mostrato una sola volta: lo salveremo in una variabile d'ambiente chiamata CLOUDFLARE_API_TOKEN, evitando di inserirlo nel codice sorgente o nei file di configurazione versionati.
La struttura delle risposte dell'API v4
Tutti gli endpoint dell'API si trovano sotto l'URL base https://api.cloudflare.com/client/v4/ e restituiscono un involucro JSON uniforme, indipendentemente dall'operazione eseguita:
{
"success": true,
"errors": [],
"messages": [],
"result": [
{
"id": "372e67954025e0ba6aaa6d586b9e0b59",
"type": "A",
"name": "www.example.com",
"content": "198.51.100.4",
"proxied": true,
"ttl": 1
}
],
"result_info": {
"page": 1,
"per_page": 100,
"count": 1,
"total_count": 1,
"total_pages": 1
}
}
Il campo success indica l'esito dell'operazione, errors contiene una lista di oggetti con code e message, result contiene il dato vero e proprio (un oggetto o un array) e result_info, presente solo negli endpoint che restituiscono collezioni, descrive lo stato della paginazione. Questa uniformità ci permette di modellare l'involucro una sola volta con un tipo generico.
Gli endpoint che useremo sono i seguenti:
GET /user/tokens/verifyper verificare la validità del token;GET /zones?name=example.comper ottenere l'identificativo di una zona;GET /zones/{zone_id}/dns_recordsper elencare i record, con filtri opzionali su tipo e nome;POST /zones/{zone_id}/dns_recordsper creare un record;PUT /zones/{zone_id}/dns_records/{record_id}per sostituire completamente un record;PATCH /zones/{zone_id}/dns_records/{record_id}per modificare solo alcuni campi;DELETE /zones/{zone_id}/dns_records/{record_id}per eliminare un record;POST /zones/{zone_id}/dns_records/batchper eseguire più operazioni in un'unica richiesta.
Configurazione del progetto
Useremo Java 21 e Maven. L'unica dipendenza esterna è jackson-databind, che dalla versione 2.12 supporta nativamente i record Java:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>cloudflare-dns</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>
</dependencies>
</project>
La struttura dei sorgenti sarà la seguente:
src/main/java/com/example/cloudflare/
├── ApiError.java
├── ApiResponse.java
├── BatchRequest.java
├── BatchResult.java
├── CloudflareClient.java
├── CloudflareException.java
├── DnsRecord.java
├── DynamicDns.java
├── ResultInfo.java
├── TokenStatus.java
└── Zone.java
Il modello dei dati
Le API di Cloudflare usano la convenzione snake_case per i nomi dei campi. Invece di annotare ogni componente con @JsonProperty, configureremo l'ObjectMapper con la strategia PropertyNamingStrategies.SNAKE_CASE: in questo modo resultInfo verrà automaticamente mappato su result_info e totalCount su total_count.
Iniziamo dagli elementi dell'involucro:
package com.example.cloudflare;
// Singolo errore o messaggio restituito dall'API
public record ApiError(int code, String message) {
@Override
public String toString() {
return "[" + code + "] " + message;
}
}
package com.example.cloudflare;
// Metadati di paginazione presenti nelle risposte che restituiscono collezioni
public record ResultInfo(
int page,
int perPage,
int count,
int totalCount,
int totalPages
) {
}
package com.example.cloudflare;
import java.util.List;
// Involucro generico comune a tutte le risposte dell'API v4
public record ApiResponse<T>(
boolean success,
List<ApiError> errors,
List<ApiError> messages,
T result,
ResultInfo resultInfo
) {
}
Passiamo ora alle entità di dominio. Per la zona ci bastano identificativo, nome e stato; per la verifica del token, identificativo e stato:
package com.example.cloudflare;
// Zona DNS gestita da Cloudflare (ad esempio example.com)
public record Zone(String id, String name, String status) {
}
package com.example.cloudflare;
// Risultato della verifica del token: lo stato atteso è "active"
public record TokenStatus(String id, String status) {
public boolean isActive() {
return "active".equals(status);
}
}
Il record DNS è l'entità centrale. Usiamo tipi wrapper (Integer, Boolean) anziché primitivi perché un valore null deve significare "campo non specificato": grazie all'inclusione NON_NULL configurata sul mapper, questi campi non verranno serializzati, requisito fondamentale per le richieste PATCH in cui si inviano solo i campi da modificare.
package com.example.cloudflare;
import java.util.List;
// Record DNS; i campi null non vengono serializzati nelle richieste
public record DnsRecord(
String id,
String type,
String name,
String content,
Integer ttl,
Boolean proxied,
Integer priority,
String comment,
List<String> tags
) {
// Il valore 1 indica a Cloudflare di usare il TTL automatico
public static final int TTL_AUTO = 1;
// Crea un record completo per le operazioni POST e PUT
public static DnsRecord of(String type, String name, String content, int ttl, boolean proxied) {
return new DnsRecord(null, type, name, content, ttl, proxied, null, null, null);
}
// Crea un record MX, che richiede il campo priority
public static DnsRecord mx(String name, String mailServer, int priority) {
return new DnsRecord(null, "MX", name, mailServer, TTL_AUTO, null, priority, null, null);
}
// Crea un oggetto parziale contenente solo il nuovo contenuto (per PATCH)
public static DnsRecord contentOnly(String content) {
return new DnsRecord(null, null, null, content, null, null, null, null, null);
}
// Restituisce una copia del record con un commento associato
public DnsRecord withComment(String newComment) {
return new DnsRecord(id, type, name, content, ttl, proxied, priority, newComment, tags);
}
// Restituisce una copia del record con l'identificativo impostato
public DnsRecord withId(String newId) {
return new DnsRecord(newId, type, name, content, ttl, proxied, priority, comment, tags);
}
}
Ricordiamo che il campo proxied è valido solo per i record di tipo A, AAAA e CNAME: quando è true il traffico passa attraverso la rete di Cloudflare e il TTL viene forzato ad automatico.
Infine, le strutture per le operazioni batch. L'endpoint accetta quattro liste opzionali (deletes, patches, puts, posts) e le esegue in quest'ordine, all'interno di un'unica transazione:
package com.example.cloudflare;
import java.util.ArrayList;
import java.util.List;
// Richiesta batch: le operazioni vengono eseguite nell'ordine deletes, patches, puts, posts
public record BatchRequest(
List<RecordId> deletes,
List<DnsRecord> patches,
List<DnsRecord> puts,
List<DnsRecord> posts
) {
// Riferimento a un record tramite il solo identificativo
public record RecordId(String id) {
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private final List<RecordId> deletes = new ArrayList<>();
private final List<DnsRecord> patches = new ArrayList<>();
private final List<DnsRecord> puts = new ArrayList<>();
private final List<DnsRecord> posts = new ArrayList<>();
public Builder delete(String recordId) {
deletes.add(new RecordId(recordId));
return this;
}
// Il record passato deve contenere l'identificativo
public Builder patch(DnsRecord record) {
requireId(record);
patches.add(record);
return this;
}
// Il record passato deve contenere l'identificativo
public Builder put(DnsRecord record) {
requireId(record);
puts.add(record);
return this;
}
public Builder post(DnsRecord record) {
posts.add(record);
return this;
}
// Le liste vuote vengono convertite in null per non essere serializzate
public BatchRequest build() {
return new BatchRequest(
deletes.isEmpty() ? null : List.copyOf(deletes),
patches.isEmpty() ? null : List.copyOf(patches),
puts.isEmpty() ? null : List.copyOf(puts),
posts.isEmpty() ? null : List.copyOf(posts)
);
}
private static void requireId(DnsRecord record) {
if (record.id() == null || record.id().isBlank()) {
throw new IllegalArgumentException("Il record deve avere un id per PATCH e PUT");
}
}
}
}
package com.example.cloudflare;
import java.util.List;
// Risultato di un'operazione batch, suddiviso per tipo di operazione
public record BatchResult(
List<DnsRecord> deletes,
List<DnsRecord> patches,
List<DnsRecord> puts,
List<DnsRecord> posts
) {
}
L'eccezione applicativa
Gli errori possono avere tre origini: problemi di rete, risposte HTTP con codice di errore e risposte con codice 200 ma success impostato a false. Raccogliamo tutti questi casi in un'unica eccezione non controllata che conserva il codice di stato e la lista degli errori restituiti dall'API:
package com.example.cloudflare;
import java.util.List;
import java.util.stream.Collectors;
// Eccezione sollevata per qualunque errore di comunicazione con l'API
public class CloudflareException extends RuntimeException {
private final int statusCode;
private final List<ApiError> errors;
public CloudflareException(int statusCode, List<ApiError> errors) {
super(buildMessage(statusCode, errors));
this.statusCode = statusCode;
this.errors = errors == null ? List.of() : List.copyOf(errors);
}
public CloudflareException(String message, Throwable cause) {
super(message, cause);
this.statusCode = -1;
this.errors = List.of();
}
public int statusCode() {
return statusCode;
}
public List<ApiError> errors() {
return errors;
}
// Verifica se tra gli errori è presente un codice specifico
public boolean hasErrorCode(int code) {
return errors.stream().anyMatch(e -> e.code() == code);
}
private static String buildMessage(int statusCode, List<ApiError> errors) {
if (errors == null || errors.isEmpty()) {
return "Errore API Cloudflare (HTTP " + statusCode + ")";
}
String details = errors.stream()
.map(ApiError::toString)
.collect(Collectors.joining("; "));
return "Errore API Cloudflare (HTTP " + statusCode + "): " + details;
}
}
Il client HTTP
Il cuore dell'implementazione è la classe CloudflareClient. Le scelte principali sono:
- un'unica istanza di
HttpClientriutilizzata per tutte le richieste, così da sfruttare il pool di connessioni e HTTP/2; - un metodo privato
sendche costruisce la richiesta, gestisce i tentativi ripetuti e deserializza l'involucro usando unJavaTypeparametrico; - i tentativi ripetuti vengono eseguiti sempre in caso di risposta
429(la richiesta non è stata elaborata), mentre per errori di rete e risposte5xxsolo per i metodi idempotenti (GET,PUT,DELETE), per evitare di creare record duplicati ripetendo unaPOSTche potrebbe essere già andata a buon fine; - un'attesa esponenziale con jitter, che rispetta l'intestazione
Retry-Afterquando presente.
package com.example.cloudflare;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.type.TypeFactory;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
public final class CloudflareClient {
private static final URI BASE_URI = URI.create("https://api.cloudflare.com/client/v4/");
private static final int MAX_ATTEMPTS = 4;
private static final int PAGE_SIZE = 100;
private static final Set<String> IDEMPOTENT_METHODS = Set.of("GET", "PUT", "DELETE");
private final HttpClient http;
private final ObjectMapper mapper;
private final TypeFactory types;
private final String token;
public CloudflareClient(String token) {
if (token == null || token.isBlank()) {
throw new IllegalArgumentException("Il token API non può essere vuoto");
}
this.token = token;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.version(HttpClient.Version.HTTP_2)
.build();
this.mapper = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
this.types = mapper.getTypeFactory();
}
// Crea il client leggendo il token dalla variabile d'ambiente CLOUDFLARE_API_TOKEN
public static CloudflareClient fromEnvironment() {
String token = System.getenv("CLOUDFLARE_API_TOKEN");
if (token == null || token.isBlank()) {
throw new IllegalStateException("Variabile d'ambiente CLOUDFLARE_API_TOKEN non impostata");
}
return new CloudflareClient(token);
}
// ---------------------------------------------------------------
// Token e zone
// ---------------------------------------------------------------
public TokenStatus verifyToken() {
ApiResponse<TokenStatus> response =
send("GET", "user/tokens/verify", null, types.constructType(TokenStatus.class));
return response.result();
}
// Restituisce la zona con il nome indicato, se accessibile dal token
public Optional<Zone> findZone(String zoneName) {
String path = "zones" + query(Map.of("name", zoneName));
ApiResponse<List<Zone>> response = send("GET", path, null, listOf(Zone.class));
return response.result().stream().findFirst();
}
// Restituisce l'identificativo della zona o solleva un'eccezione se non esiste
public String requireZoneId(String zoneName) {
return findZone(zoneName)
.map(Zone::id)
.orElseThrow(() -> new IllegalArgumentException("Zona non trovata: " + zoneName));
}
// ---------------------------------------------------------------
// Record DNS
// ---------------------------------------------------------------
// Elenca tutti i record della zona, attraversando tutte le pagine; type e name sono filtri opzionali
public List<DnsRecord> listDnsRecords(String zoneId, String type, String name) {
List<DnsRecord> all = new ArrayList<>();
int page = 1;
int totalPages;
do {
Map<String, String> params = new LinkedHashMap<>();
params.put("type", type);
params.put("name", name);
params.put("page", String.valueOf(page));
params.put("per_page", String.valueOf(PAGE_SIZE));
String path = "zones/" + zoneId + "/dns_records" + query(params);
ApiResponse<List<DnsRecord>> response = send("GET", path, null, listOf(DnsRecord.class));
all.addAll(response.result());
// In assenza di result_info si assume un'unica pagina
totalPages = response.resultInfo() != null ? response.resultInfo().totalPages() : 1;
page++;
} while (page <= totalPages);
return all;
}
public List<DnsRecord> listDnsRecords(String zoneId) {
return listDnsRecords(zoneId, null, null);
}
public DnsRecord getDnsRecord(String zoneId, String recordId) {
String path = "zones/" + zoneId + "/dns_records/" + recordId;
return send("GET", path, null, types.constructType(DnsRecord.class)).result();
}
public DnsRecord createDnsRecord(String zoneId, DnsRecord record) {
String path = "zones/" + zoneId + "/dns_records";
return send("POST", path, record, types.constructType(DnsRecord.class)).result();
}
// Sostituisce interamente il record: i campi omessi tornano ai valori predefiniti
public DnsRecord updateDnsRecord(String zoneId, String recordId, DnsRecord record) {
String path = "zones/" + zoneId + "/dns_records/" + recordId;
return send("PUT", path, record, types.constructType(DnsRecord.class)).result();
}
// Modifica solo i campi non null del record passato
public DnsRecord patchDnsRecord(String zoneId, String recordId, DnsRecord changes) {
String path = "zones/" + zoneId + "/dns_records/" + recordId;
return send("PATCH", path, changes, types.constructType(DnsRecord.class)).result();
}
// Elimina il record e restituisce l'identificativo confermato dall'API
public String deleteDnsRecord(String zoneId, String recordId) {
String path = "zones/" + zoneId + "/dns_records/" + recordId;
ApiResponse<BatchRequest.RecordId> response =
send("DELETE", path, null, types.constructType(BatchRequest.RecordId.class));
return response.result().id();
}
// Esegue più operazioni in modo atomico: se una fallisce, nessuna viene applicata
public BatchResult batch(String zoneId, BatchRequest request) {
String path = "zones/" + zoneId + "/dns_records/batch";
return send("POST", path, request, types.constructType(BatchResult.class)).result();
}
// ---------------------------------------------------------------
// Infrastruttura HTTP
// ---------------------------------------------------------------
private <T> ApiResponse<T> send(String method, String path, Object body, JavaType resultType) {
HttpRequest request = buildRequest(method, path, body);
JavaType envelopeType = types.constructParametricType(ApiResponse.class, resultType);
boolean idempotent = IDEMPOTENT_METHODS.contains(method);
for (int attempt = 1; ; attempt++) {
HttpResponse<String> response;
try {
response = http.send(request, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) {
if (idempotent && attempt < MAX_ATTEMPTS) {
sleep(backoff(attempt, null));
continue;
}
throw new CloudflareException("Errore di rete verso l'API Cloudflare: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CloudflareException("Richiesta interrotta", e);
}
int status = response.statusCode();
boolean retryable = status == 429 || (status >= 500 && idempotent);
if (retryable && attempt < MAX_ATTEMPTS) {
sleep(backoff(attempt, response.headers().firstValue("Retry-After").orElse(null)));
continue;
}
ApiResponse<T> parsed = parse(response.body(), envelopeType, status);
if (status >= 400 || !parsed.success()) {
throw new CloudflareException(status, parsed.errors());
}
return parsed;
}
}
private HttpRequest buildRequest(String method, String path, Object body) {
HttpRequest.BodyPublisher publisher = body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(toJson(body), StandardCharsets.UTF_8);
return HttpRequest.newBuilder(BASE_URI.resolve(path))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + token)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.method(method, publisher)
.build();
}
private <T> ApiResponse<T> parse(String body, JavaType envelopeType, int status) {
try {
return mapper.readValue(body, envelopeType);
} catch (JsonProcessingException e) {
// Ad esempio una pagina HTML restituita da un proxy in caso di errore 502
throw new CloudflareException(
"Risposta non valida dall'API (HTTP " + status + "): " + abbreviate(body), e);
}
}
private String toJson(Object value) {
try {
return mapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Impossibile serializzare il corpo della richiesta", e);
}
}
private JavaType listOf(Class<?> elementType) {
return types.constructCollectionType(List.class, elementType);
}
// Costruisce la query string ignorando i parametri null e codificando i valori
private static String query(Map<String, String> params) {
String encoded = params.entrySet().stream()
.filter(e -> e.getValue() != null)
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
return encoded.isEmpty() ? "" : "?" + encoded;
}
// Attesa esponenziale con jitter; Retry-After, se presente, ha la precedenza
private static Duration backoff(int attempt, String retryAfter) {
if (retryAfter != null) {
try {
return Duration.ofSeconds(Long.parseLong(retryAfter.trim()));
} catch (NumberFormatException ignored) {
// Formato data HTTP non gestito: si ricade sul calcolo esponenziale
}
}
long base = 500L * (1L << (attempt - 1));
long jitter = ThreadLocalRandom.current().nextLong(0, 250);
return Duration.ofMillis(base + jitter);
}
private static void sleep(Duration duration) {
try {
Thread.sleep(duration);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CloudflareException("Attesa interrotta", e);
}
}
private static String abbreviate(String text) {
if (text == null) {
return "";
}
return text.length() <= 200 ? text : text.substring(0, 200) + "...";
}
}
Due dettagli meritano attenzione. Il primo riguarda BASE_URI.resolve(path): l'URI base termina con una barra e i percorsi passati non iniziano con una barra, altrimenti la risoluzione sostituirebbe l'intero percorso /client/v4/. Il secondo riguarda la deserializzazione: constructParametricType(ApiResponse.class, resultType) produce a runtime il tipo concreto, ad esempio ApiResponse<List<DnsRecord>>, aggirando la cancellazione dei tipi generici di Java.
Operazioni sui record DNS
Con il client pronto, le operazioni di uso comune diventano poche righe di codice. Il seguente esempio verifica il token, risolve la zona, crea alcuni record, li modifica e infine ne elimina uno:
package com.example.cloudflare;
import java.util.List;
public class Main {
public static void main(String[] args) {
CloudflareClient client = CloudflareClient.fromEnvironment();
// Verifica preliminare del token
TokenStatus status = client.verifyToken();
if (!status.isActive()) {
throw new IllegalStateException("Token non attivo: " + status.status());
}
String zoneId = client.requireZoneId("example.com");
System.out.println("Zona: " + zoneId);
// Creazione di un record A con proxy attivo
DnsRecord api = client.createDnsRecord(zoneId,
DnsRecord.of("A", "api.example.com", "198.51.100.10", DnsRecord.TTL_AUTO, true)
.withComment("Creato da CloudflareClient"));
System.out.println("Creato: " + api.id() + " " + api.name() + " -> " + api.content());
// Creazione di un record TXT non proxato con TTL esplicito
DnsRecord txt = client.createDnsRecord(zoneId,
DnsRecord.of("TXT", "_verify.example.com", "\"token-di-verifica\"", 300, false));
// Creazione di un record MX
client.createDnsRecord(zoneId, DnsRecord.mx("example.com", "mail.example.com", 10));
// Modifica parziale: cambia solo l'indirizzo IP
DnsRecord patched = client.patchDnsRecord(zoneId, api.id(), DnsRecord.contentOnly("198.51.100.20"));
System.out.println("Aggiornato: " + patched.name() + " -> " + patched.content());
// Elenco filtrato dei record di tipo A
List<DnsRecord> aRecords = client.listDnsRecords(zoneId, "A", null);
aRecords.forEach(r -> System.out.printf("%-6s %-30s %s%n", r.type(), r.name(), r.content()));
// Eliminazione del record TXT
String deletedId = client.deleteDnsRecord(zoneId, txt.id());
System.out.println("Eliminato: " + deletedId);
}
}
È importante comprendere la differenza tra PUT e PATCH. Con PUT il record viene sostituito integralmente: se si omette il campo proxied, ad esempio, questo torna al valore predefinito false, e lo stesso vale per commenti e tag. Con PATCH si inviano soltanto i campi da modificare, ed è quindi la scelta più sicura per aggiornamenti mirati come il cambio di indirizzo IP.
Quando si tenta di creare un record identico a uno esistente, l'API risponde con un errore 400 e il codice 81058 ("An identical record already exists"). Grazie al metodo hasErrorCode possiamo rendere la creazione idempotente:
try {
client.createDnsRecord(zoneId, DnsRecord.of("A", "api.example.com", "198.51.100.20", 1, true));
} catch (CloudflareException e) {
if (e.hasErrorCode(81058)) {
// Il record esiste già con gli stessi valori: nessuna azione necessaria
System.out.println("Record già presente");
} else {
throw e;
}
}
Operazioni batch
Quando occorre applicare molte modifiche contemporaneamente, eseguire una richiesta per ogni record è lento e consuma il limite di richieste. L'endpoint /dns_records/batch accetta tutte le operazioni in un'unica chiamata e le esegue in modo transazionale: se una qualsiasi operazione fallisce, nessuna modifica viene applicata. Questo è particolarmente utile per scenari come lo spostamento di un servizio da un gruppo di server a un altro:
package com.example.cloudflare;
import java.util.List;
public class Migration {
public static void main(String[] args) {
CloudflareClient client = CloudflareClient.fromEnvironment();
String zoneId = client.requireZoneId("example.com");
// Record A attualmente associati al servizio
List<DnsRecord> current = client.listDnsRecords(zoneId, "A", "app.example.com");
BatchRequest.Builder batch = BatchRequest.builder();
// Rimozione di tutti i record attuali
current.forEach(r -> batch.delete(r.id()));
// Creazione dei nuovi record verso il nuovo gruppo di server
for (String ip : List.of("203.0.113.11", "203.0.113.12", "203.0.113.13")) {
batch.post(DnsRecord.of("A", "app.example.com", ip, DnsRecord.TTL_AUTO, true)
.withComment("Cluster B"));
}
BatchResult result = client.batch(zoneId, batch.build());
System.out.println("Eliminati: " + sizeOf(result.deletes()));
System.out.println("Creati: " + sizeOf(result.posts()));
}
private static int sizeOf(List<?> list) {
return list == null ? 0 : list.size();
}
}
Poiché le eliminazioni vengono eseguite prima delle creazioni all'interno della stessa transazione, non esiste un intervallo di tempo in cui il nome risulta privo di record, né un momento in cui vecchi e nuovi indirizzi coesistono.
Un caso d'uso completo: DNS dinamico
Un'applicazione tipica è l'aggiornamento automatico di un record quando cambia l'indirizzo IP pubblico di una connessione domestica, in sostituzione di servizi di DNS dinamico esterni. Il programma seguente rileva l'IP pubblico, lo confronta con il valore del record e aggiorna quest'ultimo solo se necessario. Se il record non esiste, viene creato.
package com.example.cloudflare;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
public class DynamicDns {
private static final URI IP_SERVICE = URI.create("https://api.ipify.org");
public static void main(String[] args) throws Exception {
String zoneName = requireEnv("CF_ZONE_NAME");
String recordName = requireEnv("CF_RECORD_NAME");
CloudflareClient client = CloudflareClient.fromEnvironment();
String publicIp = detectPublicIp();
System.out.println("IP pubblico rilevato: " + publicIp);
String zoneId = client.requireZoneId(zoneName);
List<DnsRecord> records = client.listDnsRecords(zoneId, "A", recordName);
if (records.isEmpty()) {
DnsRecord created = client.createDnsRecord(zoneId,
DnsRecord.of("A", recordName, publicIp, 300, false)
.withComment("Gestito da DynamicDns"));
System.out.println("Record creato: " + created.id());
return;
}
if (records.size() > 1) {
// Con più record A per lo stesso nome l'aggiornamento sarebbe ambiguo
throw new IllegalStateException("Trovati " + records.size() + " record A per " + recordName);
}
DnsRecord record = records.getFirst();
if (publicIp.equals(record.content())) {
System.out.println("Nessuna modifica necessaria");
return;
}
client.patchDnsRecord(zoneId, record.id(), DnsRecord.contentOnly(publicIp));
System.out.println("Record aggiornato: " + record.content() + " -> " + publicIp);
}
// Interroga un servizio esterno e valida che la risposta sia un indirizzo IPv4
private static String detectPublicIp() throws IOException, InterruptedException {
HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest request = HttpRequest.newBuilder(IP_SERVICE)
.timeout(Duration.ofSeconds(10))
.GET()
.build();
String ip = http.send(request, HttpResponse.BodyHandlers.ofString()).body().trim();
if (!ip.matches("\\d{1,3}(\\.\\d{1,3}){3}")) {
throw new IllegalStateException("Risposta inattesa dal servizio IP: " + ip);
}
// Ogni ottetto deve essere compreso tra 0 e 255
for (String octet : ip.split("\\.")) {
if (Integer.parseInt(octet) > 255) {
throw new IllegalStateException("Indirizzo IPv4 non valido: " + ip);
}
}
return ip;
}
private static String requireEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
throw new IllegalStateException("Variabile d'ambiente " + name + " non impostata");
}
return value;
}
}
Il metodo List.getFirst() è disponibile da Java 21, grazie alle sequenced collections. La validazione dell'indirizzo è volutamente rigorosa: un servizio esterno compromesso o malfunzionante non deve poter scrivere contenuti arbitrari nella zona DNS.
Per l'esecuzione periodica è sufficiente compilare il progetto in un JAR con le dipendenze incluse e pianificarlo con cron o con un timer di systemd:
*/5 * * * * CLOUDFLARE_API_TOKEN=xxxx CF_ZONE_NAME=example.com CF_RECORD_NAME=home.example.com \
java -cp /opt/cloudflare-dns/cloudflare-dns.jar com.example.cloudflare.DynamicDns >> /var/log/ddns.log 2>&1
In un contesto reale è preferibile caricare il token da un file con permessi ristretti o dalla direttiva EnvironmentFile di systemd, anziché scriverlo direttamente nella crontab.
Limiti di richieste e buone pratiche
L'API di Cloudflare applica un limite globale di 1200 richieste ogni cinque minuti per utente, oltre il quale restituisce 429 Too Many Requests. Il nostro client gestisce già questa situazione con tentativi ripetuti e attesa esponenziale, ma è buona norma ridurre alla radice il numero di chiamate:
- memorizzare l'identificativo della zona, che non cambia, invece di risolverlo a ogni esecuzione;
- usare i filtri
typeenameanziché scaricare tutti i record e filtrarli lato client; - raggruppare le modifiche multiple con l'endpoint batch;
- confrontare lo stato desiderato con quello attuale e inviare richieste solo in presenza di differenze, come avviene nell'esempio del DNS dinamico.
Considerazioni sulla sicurezza
Un token con permesso di modifica DNS consente a chi lo possiede di reindirizzare il traffico di un dominio, intercettare la posta o ottenere certificati TLS validi. Per questo motivo conviene applicare il principio del privilegio minimo: limitare il token alle sole zone necessarie, impostare una restrizione sugli indirizzi IP di origine quando il client gira da una posizione fissa, definire una data di scadenza e ruotarlo periodicamente. Il campo comment dei record, inoltre, è un modo semplice per documentare quali record sono gestiti in modo automatico, così da evitare modifiche manuali che verrebbero sovrascritte dal processo successivo.
Conclusioni
Con la sola libreria standard affiancata da Jackson abbiamo realizzato un client Java per la gestione DNS su Cloudflare che copre l'intero ciclo di vita dei record: lettura con paginazione, creazione, sostituzione, modifica parziale, eliminazione e operazioni transazionali in batch, con una gestione degli errori e dei tentativi ripetuti adeguata all'uso in produzione. La stessa struttura, basata su un involucro generico e su un metodo di invio centralizzato, può essere estesa con facilità ad altre aree dell'API di Cloudflare, come le regole di cache, i Workers o la gestione dei certificati.