Usare le API di Cloudflare per la gestione DNS con PHP
Cloudflare espone un'API REST completa che permette di gestire in modo programmatico praticamente tutto ciò che è disponibile nel pannello di controllo, a partire dai record DNS. Automatizzare la gestione DNS diventa utile in molti scenari concreti: provisioning di nuovi sottodomini per i clienti, aggiornamento dinamico dell'indirizzo IP di un server domestico, sincronizzazione della configurazione tra ambienti, verifica di domini tramite record TXT. In questo articolo costruiremo in PHP, senza dipendenze esterne, un client per l'API v4 di Cloudflare e lo useremo per leggere, creare, aggiornare ed eliminare record DNS.
Prerequisiti
Per seguire gli esempi servono:
- PHP 8.2 o superiore con l'estensione
curlejsonabilitate; - un account Cloudflare con almeno un dominio (zona) attivo;
- un API Token con i permessi adeguati sulla zona.
Tutto il codice usa declare(strict_types=1), proprietà readonly ed eccezioni tipizzate, in modo da poter essere integrato senza modifiche in un progetto moderno o in un framework come Laravel.
Autenticazione: API Token e non Global API Key
Cloudflare supporta due meccanismi di autenticazione: la vecchia Global API Key, associata all'intero account e inviata tramite le intestazioni X-Auth-Email e X-Auth-Key, e gli API Token, inviati come Bearer token nell'intestazione Authorization. La Global API Key concede accesso illimitato a tutto l'account e va evitata: un API Token può invece essere limitato a specifici permessi e a specifiche zone, può avere una scadenza e può essere revocato singolarmente.
Per creare un token si accede alla sezione My Profile > API Tokens della dashboard e si parte dal modello Edit zone DNS. I permessi minimi necessari per questo articolo sono:
Zone > Zone > Read, per risolvere il nome del dominio nel relativo Zone ID;Zone > DNS > Edit, per leggere e modificare i record.
Nella sezione Zone Resources conviene includere solo le zone che lo script deve effettivamente gestire. Il token va conservato fuori dal codice sorgente, ad esempio in una variabile d'ambiente:
export CLOUDFLARE_API_TOKEN="il-tuo-token"
export CLOUDFLARE_ZONE_NAME="example.com"
Prima di scrivere codice possiamo verificare che il token sia valido con una semplice richiesta all'endpoint di verifica:
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
La struttura delle risposte
Tutti gli endpoint dell'API v4 condividono lo stesso URL di base, https://api.cloudflare.com/client/v4, e restituiscono un oggetto JSON con una struttura uniforme:
{
"success": true,
"errors": [],
"messages": [],
"result": [
{
"id": "023e105f4ecef8ad9ca31a8372d0c353",
"name": "www.example.com",
"type": "A",
"content": "198.51.100.4",
"proxied": true,
"ttl": 1,
"comment": "Server web principale",
"tags": []
}
],
"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 un elenco di oggetti con code e message in caso di fallimento, result contiene il payload vero e proprio (un oggetto o un array) e result_info, presente negli endpoint di elenco, descrive la paginazione. Questa uniformità ci permette di centralizzare la gestione degli errori in un unico punto del client.
Le eccezioni
Iniziamo definendo un'eccezione dedicata che conservi il codice HTTP e gli errori restituiti da Cloudflare, così che il codice chiamante possa distinguere, ad esempio, un record inesistente da un problema di autorizzazione.
<?php
declare(strict_types=1);
namespace App\Cloudflare;
use RuntimeException;
final class CloudflareException extends RuntimeException
{
/**
* @param array<int, array{code?: int, message?: string}> $apiErrors
*/
public function __construct(
string $message,
public readonly int $httpStatus = 0,
public readonly array $apiErrors = [],
) {
parent::__construct($message, $httpStatus);
}
public static function fromResponse(int $httpStatus, array $body): self
{
$errors = $body['errors'] ?? [];
// Componiamo un messaggio leggibile a partire dagli errori dell'API
$details = array_map(
static fn (array $error): string => sprintf(
'[%s] %s',
$error['code'] ?? '?',
$error['message'] ?? 'Errore sconosciuto'
),
$errors
);
$message = $details !== []
? implode('; ', $details)
: sprintf('Richiesta fallita con stato HTTP %d', $httpStatus);
return new self($message, $httpStatus, $errors);
}
public function isRateLimited(): bool
{
return $this->httpStatus === 429;
}
public function isUnauthorized(): bool
{
return in_array($this->httpStatus, [401, 403], true);
}
}
Il client HTTP
Il client è responsabile di una sola cosa: inviare richieste autenticate all'API e restituire il contenuto del campo result, sollevando un'eccezione in tutti gli altri casi. Usiamo direttamente cURL per non introdurre dipendenze, ma la stessa interfaccia potrebbe essere implementata con Guzzle o con il client HTTP di Laravel.
<?php
declare(strict_types=1);
namespace App\Cloudflare;
use JsonException;
final class CloudflareClient
{
private const BASE_URL = 'https://api.cloudflare.com/client/v4';
private const MAX_RETRIES = 3;
public function __construct(
private readonly string $apiToken,
private readonly int $timeout = 15,
) {
if ($apiToken === '') {
throw new CloudflareException('Il token API non può essere vuoto');
}
}
public function get(string $path, array $query = []): array
{
return $this->request('GET', $path, $query)['result'];
}
public function post(string $path, array $payload = []): array
{
return $this->request('POST', $path, [], $payload)['result'];
}
public function patch(string $path, array $payload): array
{
return $this->request('PATCH', $path, [], $payload)['result'];
}
public function put(string $path, array $payload): array
{
return $this->request('PUT', $path, [], $payload)['result'];
}
public function delete(string $path): array
{
return $this->request('DELETE', $path)['result'] ?? [];
}
/**
* Restituisce tutti gli elementi di un endpoint paginato.
*/
public function getAll(string $path, array $query = [], int $perPage = 100): array
{
$items = [];
$page = 1;
do {
$response = $this->request('GET', $path, $query + [
'page' => $page,
'per_page' => $perPage,
]);
array_push($items, ...$response['result']);
$totalPages = $response['result_info']['total_pages'] ?? 1;
$page++;
} while ($page <= $totalPages);
return $items;
}
/**
* Esegue la richiesta e restituisce il corpo decodificato completo.
*/
public function request(
string $method,
string $path,
array $query = [],
?array $payload = null,
): array {
$url = self::BASE_URL . '/' . ltrim($path, '/');
if ($query !== []) {
$url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}
$attempt = 0;
while (true) {
$attempt++;
[$status, $body, $headers] = $this->send($method, $url, $payload);
// In caso di rate limiting attendiamo e ritentiamo
if ($status === 429 && $attempt < self::MAX_RETRIES) {
$retryAfter = (int) ($headers['retry-after'] ?? 2 ** $attempt);
sleep(max(1, $retryAfter));
continue;
}
try {
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new CloudflareException(
'Risposta non valida da Cloudflare: ' . $e->getMessage(),
$status
);
}
if ($status >= 400 || ($decoded['success'] ?? false) !== true) {
throw CloudflareException::fromResponse($status, $decoded);
}
return $decoded;
}
}
/**
* @return array{0: int, 1: string, 2: array<string, string>}
*/
private function send(string $method, string $url, ?array $payload): array
{
$headers = [];
$handle = curl_init($url);
$options = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiToken,
'Content-Type: application/json',
'Accept: application/json',
],
// Raccogliamo le intestazioni della risposta (ci serve Retry-After)
CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
];
if ($payload !== null) {
$options[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_THROW_ON_ERROR);
}
curl_setopt_array($handle, $options);
$body = curl_exec($handle);
if ($body === false) {
$error = curl_error($handle);
curl_close($handle);
throw new CloudflareException('Errore di rete: ' . $error);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return [$status, (string) $body, $headers];
}
}
Alcune scelte meritano un commento. Il metodo request() è pubblico perché in alcuni casi, come la paginazione, serve accedere anche a result_info e non solo a result. Il metodo getAll() scorre automaticamente tutte le pagine: l'endpoint dei record DNS restituisce al massimo alcune centinaia di elementi per pagina e una zona con molti record verrebbe altrimenti letta solo in parte. Infine, la gestione dello stato 429 è importante perché Cloudflare applica un limite globale di richieste per utente (nell'ordine di 1200 richieste ogni cinque minuti): uno script che aggiorna molti record in sequenza può raggiungerlo facilmente.
Il modello del record DNS
Lavorare con array associativi è comodo ma fragile. Definiamo quindi un oggetto immutabile che rappresenti un record DNS e sappia convertirsi da e verso il formato dell'API.
<?php
declare(strict_types=1);
namespace App\Cloudflare;
final class DnsRecord
{
// Il valore 1 indica a Cloudflare di usare il TTL automatico
public const TTL_AUTO = 1;
/**
* @param list<string> $tags
*/
public function __construct(
public readonly string $type,
public readonly string $name,
public readonly string $content,
public readonly int $ttl = self::TTL_AUTO,
public readonly ?bool $proxied = null,
public readonly ?int $priority = null,
public readonly ?string $comment = null,
public readonly array $tags = [],
public readonly ?string $id = null,
) {
}
public static function fromApi(array $data): self
{
return new self(
type: $data['type'],
name: $data['name'],
content: $data['content'],
ttl: (int) ($data['ttl'] ?? self::TTL_AUTO),
proxied: $data['proxied'] ?? null,
priority: isset($data['priority']) ? (int) $data['priority'] : null,
comment: $data['comment'] ?? null,
tags: $data['tags'] ?? [],
id: $data['id'] ?? null,
);
}
public function toApi(): array
{
$payload = [
'type' => $this->type,
'name' => $this->name,
'content' => $this->content,
'ttl' => $this->ttl,
];
// Il proxy è supportato solo per i record A, AAAA e CNAME
if ($this->proxied !== null && in_array($this->type, ['A', 'AAAA', 'CNAME'], true)) {
$payload['proxied'] = $this->proxied;
}
// La priorità è obbligatoria per i record MX
if ($this->priority !== null) {
$payload['priority'] = $this->priority;
}
if ($this->comment !== null) {
$payload['comment'] = $this->comment;
}
if ($this->tags !== []) {
$payload['tags'] = $this->tags;
}
return $payload;
}
public function withContent(string $content): self
{
return new self(
$this->type,
$this->name,
$content,
$this->ttl,
$this->proxied,
$this->priority,
$this->comment,
$this->tags,
$this->id,
);
}
}
Due dettagli dell'API vanno tenuti presenti. Il primo riguarda il TTL: il valore 1 significa automatico, mentre i valori espliciti devono rientrare nell'intervallo ammesso dal piano (di norma da 60 a 86400 secondi). Il secondo riguarda il campo proxied: quando un record è proxato, il traffico passa attraverso la rete di Cloudflare, il TTL è forzato ad automatico e le risoluzioni pubbliche restituiscono gli indirizzi di Cloudflare anziché quello di origine. Per record che non trasportano traffico HTTP, come quelli usati da un server di posta, il proxy va disattivato.
Il gestore dei record
Con il client e il modello a disposizione possiamo scrivere la classe che espone le operazioni di alto livello. Le API DNS lavorano sempre nel contesto di una zona identificata da un ID esadecimale: il primo metodo si occupa quindi di risolvere il nome del dominio nel relativo Zone ID.
<?php
declare(strict_types=1);
namespace App\Cloudflare;
final class DnsManager
{
/** @var array<string, string> cache nome zona => zone id */
private array $zoneIds = [];
public function __construct(
private readonly CloudflareClient $client,
) {
}
public function resolveZoneId(string $zoneName): string
{
if (isset($this->zoneIds[$zoneName])) {
return $this->zoneIds[$zoneName];
}
$zones = $this->client->get('zones', ['name' => $zoneName]);
if ($zones === []) {
throw new CloudflareException(sprintf(
'Zona "%s" non trovata o non accessibile con il token corrente',
$zoneName
));
}
return $this->zoneIds[$zoneName] = $zones[0]['id'];
}
/**
* Elenca i record della zona, con filtri opzionali per tipo e nome.
*
* @return list<DnsRecord>
*/
public function list(string $zoneId, ?string $type = null, ?string $name = null): array
{
$query = array_filter([
'type' => $type,
'name' => $name,
]);
$records = $this->client->getAll("zones/{$zoneId}/dns_records", $query);
return array_map(DnsRecord::fromApi(...), $records);
}
public function find(string $zoneId, string $type, string $name): ?DnsRecord
{
return $this->list($zoneId, $type, $name)[0] ?? null;
}
public function get(string $zoneId, string $recordId): DnsRecord
{
return DnsRecord::fromApi(
$this->client->get("zones/{$zoneId}/dns_records/{$recordId}")
);
}
public function create(string $zoneId, DnsRecord $record): DnsRecord
{
return DnsRecord::fromApi(
$this->client->post("zones/{$zoneId}/dns_records", $record->toApi())
);
}
/**
* Sostituisce completamente il record (PUT).
*/
public function overwrite(string $zoneId, string $recordId, DnsRecord $record): DnsRecord
{
return DnsRecord::fromApi(
$this->client->put("zones/{$zoneId}/dns_records/{$recordId}", $record->toApi())
);
}
/**
* Modifica solo i campi indicati (PATCH).
*/
public function update(string $zoneId, string $recordId, array $fields): DnsRecord
{
return DnsRecord::fromApi(
$this->client->patch("zones/{$zoneId}/dns_records/{$recordId}", $fields)
);
}
public function delete(string $zoneId, string $recordId): void
{
$this->client->delete("zones/{$zoneId}/dns_records/{$recordId}");
}
/**
* Crea il record se non esiste, altrimenti lo aggiorna se necessario.
*/
public function upsert(string $zoneId, DnsRecord $record): DnsRecord
{
$existing = $this->find($zoneId, $record->type, $record->name);
if ($existing === null) {
return $this->create($zoneId, $record);
}
// Evitiamo chiamate inutili se il contenuto è già corretto
if ($existing->content === $record->content
&& $existing->proxied === ($record->proxied ?? $existing->proxied)
&& $existing->ttl === $record->ttl
) {
return $existing;
}
return $this->overwrite($zoneId, $existing->id, $record);
}
}
La differenza tra PUT e PATCH è sostanziale. Con PUT il record viene sostituito per intero: i campi non inviati, come comment o tags, tornano al valore predefinito. Con PATCH vengono modificati solo i campi presenti nel payload, ed è la scelta più sicura quando si vuole cambiare un singolo attributo, ad esempio attivare o disattivare il proxy.
Il metodo upsert() rende le operazioni idempotenti: eseguire più volte lo stesso script produce sempre lo stesso stato finale, senza generare errori per record duplicati. Cloudflare infatti rifiuta la creazione di un record identico a uno esistente (codice di errore 81058) e di un CNAME con lo stesso nome di un altro record.
Operazioni di base
Vediamo ora il gestore in azione. Lo script seguente elenca i record della zona, crea un record A, ne aggiorna il contenuto, aggiunge un record TXT e infine elimina il record creato.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use App\Cloudflare\CloudflareClient;
use App\Cloudflare\CloudflareException;
use App\Cloudflare\DnsManager;
use App\Cloudflare\DnsRecord;
$client = new CloudflareClient((string) getenv('CLOUDFLARE_API_TOKEN'));
$dns = new DnsManager($client);
try {
$zoneId = $dns->resolveZoneId((string) getenv('CLOUDFLARE_ZONE_NAME'));
// Elenco di tutti i record della zona
foreach ($dns->list($zoneId) as $record) {
printf(
"%-6s %-35s %-40s %s\n",
$record->type,
$record->name,
$record->content,
$record->proxied ? 'proxied' : 'dns-only'
);
}
// Creazione di un record A
$record = $dns->create($zoneId, new DnsRecord(
type: 'A',
name: 'staging.example.com',
content: '203.0.113.10',
proxied: true,
comment: 'Ambiente di staging',
tags: ['env:staging'],
));
echo "Creato record {$record->id}\n";
// Aggiornamento parziale: cambiamo solo l'indirizzo IP
$record = $dns->update($zoneId, $record->id, ['content' => '203.0.113.20']);
echo "Nuovo contenuto: {$record->content}\n";
// Record TXT per la verifica di un dominio
$dns->upsert($zoneId, new DnsRecord(
type: 'TXT',
name: '_verification.example.com',
content: '"token-di-verifica-123"',
ttl: 300,
));
// Record MX: la priorità è obbligatoria e il proxy non è ammesso
$dns->upsert($zoneId, new DnsRecord(
type: 'MX',
name: 'example.com',
content: 'mail.example.com',
ttl: 3600,
priority: 10,
));
// Eliminazione del record di staging
$dns->delete($zoneId, $record->id);
echo "Record eliminato\n";
} catch (CloudflareException $e) {
fwrite(STDERR, sprintf("Errore Cloudflare (HTTP %d): %s\n", $e->httpStatus, $e->getMessage()));
exit(1);
}
Il nome del record può essere indicato sia in forma completa (staging.example.com) sia in forma relativa (staging), mentre @ identifica l'apice della zona. Nelle risposte, invece, Cloudflare restituisce sempre il nome completo: per questo nei filtri di ricerca conviene usare sempre il nome completo, così da poter confrontare direttamente i valori.
Per i record TXT è buona norma racchiudere il contenuto tra virgolette: Cloudflare le aggiunge comunque, ma in loro assenza la dashboard mostra un avviso e il confronto con il valore restituito dall'API potrebbe fallire.
Un client DNS dinamico
Un caso d'uso molto diffuso è il DNS dinamico: un server collegato a una linea con IP pubblico variabile deve aggiornare periodicamente il proprio record A. Con le classi già scritte l'implementazione richiede poche righe.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use App\Cloudflare\CloudflareClient;
use App\Cloudflare\CloudflareException;
use App\Cloudflare\DnsManager;
use App\Cloudflare\DnsRecord;
function detectPublicIp(): string
{
// Interroghiamo più servizi per non dipendere da uno solo
$providers = [
'https://api.ipify.org',
'https://ifconfig.me/ip',
'https://icanhazip.com',
];
$context = stream_context_create(['http' => ['timeout' => 5]]);
foreach ($providers as $url) {
$ip = trim((string) @file_get_contents($url, false, $context));
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $ip;
}
}
throw new RuntimeException('Impossibile determinare l\'IP pubblico');
}
$hostname = 'home.example.com';
try {
$dns = new DnsManager(new CloudflareClient((string) getenv('CLOUDFLARE_API_TOKEN')));
$zoneId = $dns->resolveZoneId('example.com');
$currentIp = detectPublicIp();
$existing = $dns->find($zoneId, 'A', $hostname);
if ($existing !== null && $existing->content === $currentIp) {
echo "Nessuna modifica: {$hostname} punta già a {$currentIp}\n";
exit(0);
}
$dns->upsert($zoneId, new DnsRecord(
type: 'A',
name: $hostname,
content: $currentIp,
ttl: 60,
proxied: false,
comment: 'Aggiornato da DDNS il ' . date('c'),
));
echo "Aggiornato {$hostname} a {$currentIp}\n";
} catch (CloudflareException | RuntimeException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
Lo script può essere eseguito con cron ogni cinque minuti:
*/5 * * * * CLOUDFLARE_API_TOKEN=il-tuo-token /usr/bin/php /opt/ddns/update.php >> /var/log/ddns.log 2>&1
Grazie al controllo preliminare sul contenuto, lo script effettua una sola richiesta di lettura quando l'IP non cambia, restando ampiamente entro i limiti dell'API. Il TTL basso e il proxy disattivato fanno sì che il nuovo indirizzo si propaghi rapidamente e che il record sia utilizzabile anche per servizi non HTTP come SSH o VPN.
Operazioni in blocco con l'endpoint batch
Quando si devono modificare molti record, eseguire una richiesta per ciascuno è lento e consuma rapidamente la quota di richieste. Cloudflare mette a disposizione l'endpoint POST /zones/{zone_id}/dns_records/batch, che accetta in un'unica chiamata quattro liste di operazioni: deletes, patches, puts e posts. Le operazioni sono eseguite in questo ordine all'interno di una singola transazione: se una fallisce, nessuna modifica viene applicata.
Aggiungiamo al gestore un metodo dedicato:
/**
* Applica più modifiche in un'unica transazione.
*
* @param list<string> $deleteIds ID dei record da eliminare
* @param array<string, array> $patches ID => campi da modificare
* @param list<DnsRecord> $creates nuovi record da creare
*/
public function batch(
string $zoneId,
array $deleteIds = [],
array $patches = [],
array $creates = [],
): array {
$payload = [];
if ($deleteIds !== []) {
$payload['deletes'] = array_map(
static fn (string $id): array => ['id' => $id],
$deleteIds
);
}
if ($patches !== []) {
$payload['patches'] = array_map(
static fn (string $id, array $fields): array => ['id' => $id] + $fields,
array_keys($patches),
$patches
);
}
if ($creates !== []) {
$payload['posts'] = array_map(
static fn (DnsRecord $record): array => $record->toApi(),
$creates
);
}
return $this->client->post("zones/{$zoneId}/dns_records/batch", $payload);
}
Un esempio tipico è la migrazione di un gruppo di record verso un nuovo server: con una sola chiamata si possono aggiornare tutti i record che puntano al vecchio indirizzo.
$oldIp = '198.51.100.4';
$newIp = '203.0.113.50';
// Individuiamo tutti i record A che puntano al vecchio server
$toMigrate = array_filter(
$dns->list($zoneId, 'A'),
static fn (DnsRecord $record): bool => $record->content === $oldIp
);
$patches = [];
foreach ($toMigrate as $record) {
$patches[$record->id] = ['content' => $newIp];
}
if ($patches !== []) {
$dns->batch($zoneId, patches: $patches);
printf("Migrati %d record verso %s\n", count($patches), $newIp);
}
Sincronizzazione dichiarativa
L'approccio più robusto per gestire la configurazione DNS di un progetto è quello dichiarativo: si descrive lo stato desiderato in un file versionato e uno script calcola le differenze con lo stato attuale, applicando solo le modifiche necessarie. È lo stesso principio di strumenti come Terraform, realizzato qui in forma minimale.
Partiamo da un file di configurazione in formato PHP:
<?php
// dns.php: stato desiderato della zona
return [
['type' => 'A', 'name' => 'example.com', 'content' => '203.0.113.50', 'proxied' => true],
['type' => 'A', 'name' => 'www.example.com', 'content' => '203.0.113.50', 'proxied' => true],
['type' => 'CNAME', 'name' => 'api.example.com', 'content' => 'example.com', 'proxied' => true],
['type' => 'MX', 'name' => 'example.com', 'content' => 'mail.example.com', 'priority' => 10],
['type' => 'TXT', 'name' => 'example.com', 'content' => '"v=spf1 mx -all"'],
];
La classe di sincronizzazione confronta i record desiderati con quelli presenti, usando la coppia tipo e nome come chiave, e costruisce un'unica richiesta batch. Per sicurezza gestisce solo i record contrassegnati con un tag specifico, così da non eliminare record creati manualmente o da altri strumenti.
<?php
declare(strict_types=1);
namespace App\Cloudflare;
final class ZoneSynchronizer
{
private const MANAGED_TAG = 'managed:php-sync';
public function __construct(
private readonly DnsManager $dns,
) {
}
/**
* @param list<array> $desired
* @return array{create: int, update: int, delete: int}
*/
public function sync(string $zoneId, array $desired, bool $dryRun = true): array
{
$desiredRecords = [];
foreach ($desired as $item) {
$record = DnsRecord::fromApi($item + [
'ttl' => DnsRecord::TTL_AUTO,
'tags' => [self::MANAGED_TAG],
]);
$desiredRecords[$this->key($record)] = $record;
}
// Consideriamo solo i record gestiti da questo strumento
$current = [];
foreach ($this->dns->list($zoneId) as $record) {
if (in_array(self::MANAGED_TAG, $record->tags, true)) {
$current[$this->key($record)] = $record;
}
}
$creates = [];
$patches = [];
$deletes = [];
foreach ($desiredRecords as $key => $record) {
if (!isset($current[$key])) {
$creates[] = $record;
continue;
}
$existing = $current[$key];
if ($existing->content !== $record->content
|| ($record->proxied !== null && $existing->proxied !== $record->proxied)
|| $existing->priority !== $record->priority
) {
$patches[$existing->id] = $record->toApi();
}
}
foreach ($current as $key => $record) {
if (!isset($desiredRecords[$key])) {
$deletes[] = $record->id;
}
}
if (!$dryRun && ($creates || $patches || $deletes)) {
$this->dns->batch($zoneId, $deletes, $patches, $creates);
}
return [
'create' => count($creates),
'update' => count($patches),
'delete' => count($deletes),
];
}
private function key(DnsRecord $record): string
{
// Per MX e TXT più record possono condividere lo stesso nome
$suffix = in_array($record->type, ['MX', 'TXT'], true) ? '|' . $record->content : '';
return strtolower($record->type . '|' . rtrim($record->name, '.')) . $suffix;
}
}
L'esecuzione predefinita è in modalità dry run: lo script mostra cosa cambierebbe senza toccare la zona. Solo con un flag esplicito le modifiche vengono applicate.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use App\Cloudflare\CloudflareClient;
use App\Cloudflare\DnsManager;
use App\Cloudflare\ZoneSynchronizer;
$apply = in_array('--apply', $argv, true);
$dns = new DnsManager(new CloudflareClient((string) getenv('CLOUDFLARE_API_TOKEN')));
$zoneId = $dns->resolveZoneId('example.com');
$result = (new ZoneSynchronizer($dns))->sync($zoneId, require __DIR__ . '/dns.php', !$apply);
printf(
"%s: %d da creare, %d da aggiornare, %d da eliminare\n",
$apply ? 'Applicato' : 'Anteprima',
$result['create'],
$result['update'],
$result['delete']
);
Inserito in una pipeline CI, questo script permette di gestire il DNS con le stesse regole del codice applicativo: modifiche tramite merge request, revisione, cronologia completa e possibilità di rollback.
Esportazione e backup della zona
Prima di qualsiasi modifica massiva conviene salvare lo stato della zona. L'endpoint GET /zones/{zone_id}/dns_records/export restituisce la zona in formato BIND. A differenza degli altri endpoint, la risposta è testo semplice e non JSON, per cui non possiamo passare dal metodo request() del client. La soluzione più semplice è salvare in JSON l'elenco dei record ottenuto tramite l'API standard:
$records = array_map(
static fn (DnsRecord $record): array => ['id' => $record->id] + $record->toApi(),
$dns->list($zoneId)
);
$file = sprintf('backup-%s-%s.json', 'example.com', date('Ymd-His'));
file_put_contents(
$file,
json_encode($records, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
);
echo "Backup salvato in {$file}\n";
Il file ottenuto ha lo stesso formato accettato dal sincronizzatore, quindi può essere usato direttamente anche per ripristinare la zona.
Buone pratiche
- Principio del privilegio minimo: ogni script dovrebbe avere un proprio token, limitato alle sole zone e ai soli permessi necessari, con restrizioni sull'indirizzo IP di provenienza quando possibile.
- Segreti fuori dal codice: il token va letto da variabili d'ambiente o da un gestore di segreti, mai scritto nel repository.
- Idempotenza: preferire operazioni di tipo upsert e confrontare lo stato prima di scrivere, riducendo sia gli errori sia il numero di chiamate.
- PATCH al posto di PUT quando si modificano singoli attributi, per non perdere commenti e tag.
- Batch per le modifiche multiple: una sola transazione è più veloce, rispetta i limiti dell'API e garantisce che la zona non resti in uno stato intermedio.
- Tag per i record gestiti: distinguere i record creati dagli script da quelli manuali evita cancellazioni accidentali.
- Dry run e backup: ogni strumento che può eliminare record dovrebbe offrire un'anteprima e salvare lo stato precedente.
Conclusioni
L'API v4 di Cloudflare è coerente e ben strutturata: un unico formato di risposta, autenticazione tramite token con permessi granulari ed endpoint REST prevedibili. Con poche classi PHP senza dipendenze abbiamo costruito un client con gestione degli errori e dei limiti di frequenza, un modello tipizzato per i record, un gestore con operazioni idempotenti, un client DNS dinamico e uno strumento di sincronizzazione dichiarativa basato su richieste batch transazionali. Da qui è semplice estendere il lavoro ad altre aree dell'API, come la gestione delle regole di cache, dei certificati o delle impostazioni di sicurezza della zona, riutilizzando lo stesso client.