Usare le API di Cloudflare per la gestione DNS con Laravel

Usare le API di Cloudflare per la gestione DNS con Laravel

Cloudflare espone un'API REST completa (la versione 4, raggiungibile su https://api.cloudflare.com/client/v4) che permette di gestire in modo programmatico praticamente tutto ciò che si può fare dal pannello di controllo. Tra le operazioni più utili in un contesto applicativo c'è la gestione dei record DNS: creare sottodomini al volo per i clienti di una piattaforma SaaS, aggiornare un record A quando cambia l'IP di un server, verificare la proprietà di un dominio tramite record TXT o ripulire record obsoleti in blocco.

In questo articolo vedremo come costruire in Laravel un client dedicato per le API DNS di Cloudflare, basato sull'HTTP client del framework, con gestione degli errori, paginazione, operazioni batch, comandi Artisan, job in coda e test automatizzati senza chiamate di rete reali.

Prerequisiti e creazione del token API

Cloudflare supporta due metodi di autenticazione: la vecchia Global API Key (associata all'intero account, con header X-Auth-Email e X-Auth-Key) e gli API Token, che vanno sempre preferiti perché possono essere limitati a permessi e zone specifiche e revocati singolarmente.

Dalla dashboard, nella sezione My Profile → API Tokens, si crea un token personalizzato con questi permessi minimi:

  • Zone → Zone → Read: necessario per risolvere il nome di dominio nel relativo zone_id;
  • Zone → DNS → Edit: necessario per leggere, creare, modificare ed eliminare i record;
  • Zone Resources: limitare il token alle sole zone che l'applicazione deve gestire.

Opzionalmente si può restringere il token a un insieme di indirizzi IP (quelli dei server applicativi) e impostarne una data di scadenza. Il token viene mostrato una sola volta: va salvato subito nel file .env.

CLOUDFLARE_API_TOKEN=il-tuo-token
CLOUDFLARE_ZONE_ID=
CLOUDFLARE_DEFAULT_ZONE=example.com
CLOUDFLARE_TIMEOUT=15

Prima di scrivere codice conviene verificare che il token funzioni con una semplice chiamata all'endpoint di verifica:

curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq

Una risposta con "status": "active" conferma che il token è valido.

Struttura delle risposte dell'API

Tutte le risposte dell'API v4 condividono lo stesso involucro JSON. Conoscerlo è fondamentale per scrivere un client robusto, perché lo stato HTTP da solo non basta: l'esito reale è indicato dal campo success.

{
  "success": true,
  "errors": [],
  "messages": [],
  "result": [
    {
      "id": "372e67954025e0ba6aaa6d586b9e0b59",
      "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
  }
}

In caso di errore, l'array errors contiene oggetti con un code numerico e un message descrittivo. Il campo result_info è presente solo negli endpoint che restituiscono elenchi paginati. Da notare infine il valore ttl: 1, che per Cloudflare significa TTL automatico.

Gli endpoint che useremo sono i seguenti:

  • GET /zones?name={dominio}: ricerca di una zona per nome;
  • GET /zones/{zone_id}/dns_records: elenco dei record, con filtri e paginazione;
  • GET /zones/{zone_id}/dns_records/{record_id}: dettaglio di un record;
  • POST /zones/{zone_id}/dns_records: creazione di un record;
  • PATCH /zones/{zone_id}/dns_records/{record_id}: modifica parziale;
  • PUT /zones/{zone_id}/dns_records/{record_id}: sostituzione completa;
  • DELETE /zones/{zone_id}/dns_records/{record_id}: eliminazione;
  • POST /zones/{zone_id}/dns_records/batch: più operazioni in un'unica richiesta.

Configurazione in Laravel

Seguendo la convenzione di Laravel, le credenziali dei servizi esterni vanno registrate in config/services.php, in modo che il codice non legga mai direttamente le variabili d'ambiente (cosa che smetterebbe di funzionare dopo php artisan config:cache).

<?php

return [

    // ... altri servizi

    'cloudflare' => [
        'base_url' => env('CLOUDFLARE_BASE_URL', 'https://api.cloudflare.com/client/v4'),
        'token' => env('CLOUDFLARE_API_TOKEN'),
        // Se valorizzato, evita una chiamata per risolvere la zona
        'zone_id' => env('CLOUDFLARE_ZONE_ID'),
        'default_zone' => env('CLOUDFLARE_DEFAULT_ZONE'),
        'timeout' => (int) env('CLOUDFLARE_TIMEOUT', 15),
    ],

];

Un'eccezione dedicata

Invece di propagare eccezioni generiche, conviene definire un'eccezione che trasporti gli errori restituiti da Cloudflare. Così il codice chiamante può distinguere, per esempio, un record duplicato da un problema di autenticazione.

<?php

namespace App\Services\Cloudflare\Exceptions;

use Illuminate\Http\Client\Response;
use RuntimeException;

class CloudflareException extends RuntimeException
{
    /**
     * @param array<int, array{code: int, message: string}> $errors
     */
    public function __construct(
        string $message,
        public readonly array $errors = [],
        public readonly ?int $status = null,
    ) {
        parent::__construct($message, $errors[0]['code'] ?? 0);
    }

    public static function fromResponse(Response $response): self
    {
        $errors = $response->json('errors') ?? [];

        // Unisce i messaggi di errore in una stringa leggibile
        $message = collect($errors)
            ->map(fn (array $error) => "[{$error['code']}] {$error['message']}")
            ->implode('; ');

        return new self(
            $message !== '' ? $message : 'Errore sconosciuto dalle API di Cloudflare',
            $errors,
            $response->status(),
        );
    }

    public function hasErrorCode(int $code): bool
    {
        return collect($this->errors)->contains(fn (array $error) => $error['code'] === $code);
    }
}

Rappresentare un record DNS con un DTO

Lavorare con array associativi restituiti dall'API rende il codice fragile. Un piccolo Data Transfer Object immutabile offre tipizzazione, autocompletamento nell'editor e un unico punto in cui gestire la conversione da e verso il formato di Cloudflare.

<?php

namespace App\Services\Cloudflare\Data;

final readonly class DnsRecord
{
    /**
     * @param array<int, string> $tags
     */
    public function __construct(
        public string $type,
        public string $name,
        public string $content,
        public int $ttl = 1,
        public bool $proxied = false,
        public ?int $priority = null,
        public ?string $comment = null,
        public array $tags = [],
        public ?string $id = null,
    ) {
    }

    /**
     * Crea il DTO a partire dalla risposta delle API.
     */
    public static function fromApi(array $data): self
    {
        return new self(
            type: $data['type'],
            name: $data['name'],
            content: $data['content'],
            ttl: $data['ttl'] ?? 1,
            proxied: $data['proxied'] ?? false,
            priority: $data['priority'] ?? null,
            comment: $data['comment'] ?? null,
            tags: $data['tags'] ?? [],
            id: $data['id'] ?? null,
        );
    }

    /**
     * Restituisce il payload da inviare alle API.
     */
    public function toPayload(): array
    {
        $payload = [
            'type' => $this->type,
            'name' => $this->name,
            'content' => $this->content,
            'ttl' => $this->ttl,
        ];

        // Solo i record A, AAAA e CNAME possono passare dal proxy di Cloudflare
        if (in_array($this->type, ['A', 'AAAA', 'CNAME'], true)) {
            $payload['proxied'] = $this->proxied;
        }

        // La priorità ha senso solo per MX, SRV e URI
        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,
        );
    }
}

Il client Cloudflare

Il cuore dell'integrazione è una classe di servizio che incapsula l'HTTP client di Laravel. Il metodo privato request() centralizza autenticazione, timeout, tentativi ripetuti e validazione dell'involucro di risposta; i metodi pubblici espongono operazioni con nomi espliciti.

<?php

namespace App\Services\Cloudflare;

use App\Services\Cloudflare\Data\DnsRecord;
use App\Services\Cloudflare\Exceptions\CloudflareException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\LazyCollection;

class CloudflareDnsClient
{
    public function __construct(
        private readonly string $baseUrl,
        private readonly string $token,
        private readonly int $timeout = 15,
    ) {
    }

    /**
     * Risolve il nome di dominio nel relativo zone_id, con cache.
     */
    public function zoneId(string $domain): string
    {
        return Cache::remember("cloudflare:zone:{$domain}", now()->addDay(), function () use ($domain) {
            $zones = $this->request('get', 'zones', ['name' => $domain])->json('result');

            if (empty($zones)) {
                throw new CloudflareException("Zona non trovata per il dominio {$domain}");
            }

            return $zones[0]['id'];
        });
    }

    /**
     * Restituisce tutti i record della zona, gestendo la paginazione in modo trasparente.
     *
     * @return LazyCollection<int, DnsRecord>
     */
    public function records(string $zoneId, array $filters = []): LazyCollection
    {
        return LazyCollection::make(function () use ($zoneId, $filters) {
            $page = 1;

            do {
                $response = $this->request('get', "zones/{$zoneId}/dns_records", [
                    ...$filters,
                    'page' => $page,
                    'per_page' => 100,
                ]);

                foreach ($response->json('result') as $record) {
                    yield DnsRecord::fromApi($record);
                }

                $totalPages = $response->json('result_info.total_pages', 1);
                $page++;
            } while ($page <= $totalPages);
        });
    }

    /**
     * Cerca un record esatto per tipo e nome.
     */
    public function find(string $zoneId, string $type, string $name): ?DnsRecord
    {
        return $this->records($zoneId, [
            'type' => $type,
            'name.exact' => $name,
        ])->first();
    }

    public function get(string $zoneId, string $recordId): DnsRecord
    {
        $response = $this->request('get', "zones/{$zoneId}/dns_records/{$recordId}");

        return DnsRecord::fromApi($response->json('result'));
    }

    public function create(string $zoneId, DnsRecord $record): DnsRecord
    {
        $response = $this->request('post', "zones/{$zoneId}/dns_records", $record->toPayload());

        return DnsRecord::fromApi($response->json('result'));
    }

    /**
     * Modifica parziale: vengono aggiornati solo i campi passati.
     */
    public function patch(string $zoneId, string $recordId, array $changes): DnsRecord
    {
        $response = $this->request('patch', "zones/{$zoneId}/dns_records/{$recordId}", $changes);

        return DnsRecord::fromApi($response->json('result'));
    }

    /**
     * Sostituzione completa del record.
     */
    public function replace(string $zoneId, string $recordId, DnsRecord $record): DnsRecord
    {
        $response = $this->request('put', "zones/{$zoneId}/dns_records/{$recordId}", $record->toPayload());

        return DnsRecord::fromApi($response->json('result'));
    }

    public function delete(string $zoneId, string $recordId): void
    {
        $this->request('delete', "zones/{$zoneId}/dns_records/{$recordId}");
    }

    /**
     * Crea il record se non esiste, altrimenti lo aggiorna solo se il contenuto è cambiato.
     */
    public function upsert(string $zoneId, DnsRecord $record): DnsRecord
    {
        $existing = $this->find($zoneId, $record->type, $record->name);

        if ($existing === null) {
            return $this->create($zoneId, $record);
        }

        if ($existing->content === $record->content && $existing->proxied === $record->proxied) {
            // Nessuna modifica necessaria: evitiamo una chiamata inutile
            return $existing;
        }

        return $this->replace($zoneId, $existing->id, $record);
    }

    /**
     * Esegue più operazioni in un'unica richiesta.
     * L'ordine di esecuzione lato Cloudflare è: deletes, patches, puts, posts.
     *
     * @param array<int, string> $deleteIds
     * @param array<int, array> $patches Ogni elemento deve contenere la chiave "id"
     * @param array<int, DnsRecord> $posts
     */
    public function batch(string $zoneId, array $deleteIds = [], array $patches = [], array $posts = []): array
    {
        $payload = array_filter([
            'deletes' => array_map(fn (string $id) => ['id' => $id], $deleteIds),
            'patches' => $patches,
            'posts' => array_map(fn (DnsRecord $record) => $record->toPayload(), $posts),
        ]);

        return $this->request('post', "zones/{$zoneId}/dns_records/batch", $payload)->json('result');
    }

    /**
     * Esegue la richiesta e valida l'involucro di risposta di Cloudflare.
     */
    private function request(string $method, string $uri, array $data = []): Response
    {
        try {
            $response = $this->http()->{$method}($uri, $data);
        } catch (RequestException $e) {
            throw CloudflareException::fromResponse($e->response);
        } catch (ConnectionException $e) {
            throw new CloudflareException('Impossibile contattare le API di Cloudflare: '.$e->getMessage());
        }

        // Lo stato HTTP può essere 200 anche in presenza di errori applicativi
        if ($response->json('success') !== true) {
            throw CloudflareException::fromResponse($response);
        }

        return $response;
    }

    private function http(): PendingRequest
    {
        return Http::baseUrl($this->baseUrl)
            ->withToken($this->token)
            ->acceptJson()
            ->asJson()
            ->timeout($this->timeout)
            // Ripete solo su errori di rete, 429 (rate limit) e 5xx
            ->retry(3, fn (int $attempt) => $attempt * 500, function (\Throwable $e) {
                if ($e instanceof ConnectionException) {
                    return true;
                }

                return $e instanceof RequestException
                    && ($e->response->status() === 429 || $e->response->serverError());
            })
            ->throw();
    }
}

Alcune osservazioni su questa implementazione:

  • il metodo records() restituisce una LazyCollection basata su un generatore: le pagine successive vengono richieste solo se il codice chiamante continua a iterare, quindi find() si ferma dopo il primo risultato;
  • il filtro name.exact effettua una corrispondenza esatta sul nome completo del record (FQDN); esistono anche le varianti name.contains, name.startswith e name.endswith;
  • la risoluzione della zona viene messa in cache, perché lo zone_id non cambia praticamente mai e le API di Cloudflare applicano un limite di 1200 richieste ogni cinque minuti per utente;
  • il callback di retry() evita di ripetere richieste destinate a fallire comunque, come un errore di validazione (400) o un token non autorizzato (403).

Registrazione nel service container

Il client riceve la configurazione tramite costruttore, quindi va registrato come singleton in un service provider. In questo modo può essere iniettato ovunque e sostituito facilmente nei test.

<?php

namespace App\Providers;

use App\Services\Cloudflare\CloudflareDnsClient;
use Illuminate\Support\ServiceProvider;

class CloudflareServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(CloudflareDnsClient::class, function () {
            $config = config('services.cloudflare');

            return new CloudflareDnsClient(
                baseUrl: $config['base_url'],
                token: (string) $config['token'],
                timeout: $config['timeout'],
            );
        });
    }
}

Nelle versioni recenti di Laravel il provider si registra aggiungendolo a bootstrap/providers.php:

<?php

return [
    App\Providers\AppServiceProvider::class,
    App\Providers\CloudflareServiceProvider::class,
];

Uso del client nell'applicazione

Ecco un esempio realistico: una piattaforma multi-tenant che, alla registrazione di un nuovo cliente, crea un sottodominio dedicato che punta al bilanciatore di carico e, alla cancellazione, lo rimuove.

<?php

namespace App\Actions;

use App\Models\Tenant;
use App\Services\Cloudflare\CloudflareDnsClient;
use App\Services\Cloudflare\Data\DnsRecord;
use App\Services\Cloudflare\Exceptions\CloudflareException;

class ProvisionTenantSubdomain
{
    // Codice di errore Cloudflare per record già esistente
    private const RECORD_ALREADY_EXISTS = 81053;

    public function __construct(private readonly CloudflareDnsClient $cloudflare)
    {
    }

    public function handle(Tenant $tenant): void
    {
        $zone = config('services.cloudflare.default_zone');
        $zoneId = config('services.cloudflare.zone_id') ?: $this->cloudflare->zoneId($zone);

        $record = new DnsRecord(
            type: 'CNAME',
            name: "{$tenant->slug}.{$zone}",
            content: "lb.{$zone}",
            proxied: true,
            comment: "Tenant #{$tenant->id}",
            tags: ["tenant:{$tenant->id}"],
        );

        try {
            $created = $this->cloudflare->create($zoneId, $record);
        } catch (CloudflareException $e) {
            if (! $e->hasErrorCode(self::RECORD_ALREADY_EXISTS)) {
                throw $e;
            }

            // Il record esiste già: lo riallineiamo invece di fallire
            $created = $this->cloudflare->upsert($zoneId, $record);
        }

        $tenant->update(['cloudflare_record_id' => $created->id]);
    }
}

Salvare l'identificativo del record sul modello è una buona pratica: le operazioni successive di modifica o eliminazione non richiedono una ricerca e restano corrette anche se nel frattempo il nome viene cambiato.

L'uso dei tags nel formato chiave:valore permette inoltre di filtrare i record gestiti dall'applicazione. Si noti che la disponibilità di commenti e tag dipende dal piano Cloudflare della zona: i tag, in particolare, non sono disponibili sul piano gratuito, quindi in quel caso vanno omessi dal payload.

Operazioni in blocco con l'endpoint batch

Quando occorre modificare molti record, ad esempio durante la migrazione di un'intera infrastruttura su nuovi indirizzi IP, eseguire una richiesta per record è lento e consuma rapidamente il limite di richieste. L'endpoint batch permette di combinare eliminazioni, modifiche e creazioni in un'unica chiamata, eseguita in modo transazionale: se un'operazione fallisce, nessuna modifica viene applicata.

<?php

namespace App\Actions;

use App\Services\Cloudflare\CloudflareDnsClient;

class MigrateARecords
{
    public function __construct(private readonly CloudflareDnsClient $cloudflare)
    {
    }

    /**
     * Sostituisce un indirizzo IP con un altro in tutti i record A della zona.
     */
    public function handle(string $zoneId, string $oldIp, string $newIp): int
    {
        $patches = $this->cloudflare
            ->records($zoneId, ['type' => 'A', 'content.exact' => $oldIp])
            ->map(fn ($record) => ['id' => $record->id, 'content' => $newIp])
            ->values()
            ->all();

        if ($patches === []) {
            return 0;
        }

        // Suddivide in blocchi per restare entro i limiti dell'endpoint batch
        foreach (array_chunk($patches, 200) as $chunk) {
            $this->cloudflare->batch($zoneId, patches: $chunk);
        }

        return count($patches);
    }
}

Cloudflare esegue sempre le operazioni del batch nell'ordine deletes, patches, puts, posts: è quindi possibile, nella stessa richiesta, eliminare un record e crearne uno nuovo con lo stesso nome senza incorrere in conflitti. Il numero massimo di operazioni per richiesta dipende dal piano dell'account, per cui è prudente suddividere il lavoro in blocchi come nell'esempio.

Un comando Artisan per la gestione da terminale

Un comando Artisan è comodo sia per le operazioni manuali sia per gli script di deploy. L'esempio seguente permette di elencare, creare ed eliminare record.

<?php

namespace App\Console\Commands;

use App\Services\Cloudflare\CloudflareDnsClient;
use App\Services\Cloudflare\Data\DnsRecord;
use App\Services\Cloudflare\Exceptions\CloudflareException;
use Illuminate\Console\Command;

class CloudflareDnsCommand extends Command
{
    protected $signature = 'cloudflare:dns
        {action : list, create oppure delete}
        {--zone= : Dominio della zona (predefinito da configurazione)}
        {--type=A : Tipo di record}
        {--name= : Nome completo del record}
        {--content= : Contenuto del record}
        {--ttl=1 : TTL in secondi (1 = automatico)}
        {--proxied : Attiva il proxy di Cloudflare}';

    protected $description = 'Gestisce i record DNS di una zona Cloudflare';

    public function handle(CloudflareDnsClient $cloudflare): int
    {
        $zone = $this->option('zone') ?? config('services.cloudflare.default_zone');

        try {
            $zoneId = $cloudflare->zoneId($zone);

            return match ($this->argument('action')) {
                'list' => $this->listRecords($cloudflare, $zoneId),
                'create' => $this->createRecord($cloudflare, $zoneId),
                'delete' => $this->deleteRecord($cloudflare, $zoneId),
                default => $this->invalidAction(),
            };
        } catch (CloudflareException $e) {
            $this->components->error($e->getMessage());

            return self::FAILURE;
        }
    }

    private function listRecords(CloudflareDnsClient $cloudflare, string $zoneId): int
    {
        $filters = array_filter(['type' => $this->option('type')]);

        $rows = $cloudflare->records($zoneId, $filters)
            ->map(fn (DnsRecord $r) => [
                $r->type,
                $r->name,
                $r->content,
                $r->ttl === 1 ? 'auto' : $r->ttl,
                $r->proxied ? 'sì' : 'no',
            ])
            ->all();

        $this->table(['Tipo', 'Nome', 'Contenuto', 'TTL', 'Proxy'], $rows);

        return self::SUCCESS;
    }

    private function createRecord(CloudflareDnsClient $cloudflare, string $zoneId): int
    {
        $record = $cloudflare->create($zoneId, new DnsRecord(
            type: strtoupper($this->option('type')),
            name: $this->option('name') ?? $this->ask('Nome del record'),
            content: $this->option('content') ?? $this->ask('Contenuto del record'),
            ttl: (int) $this->option('ttl'),
            proxied: (bool) $this->option('proxied'),
        ));

        $this->components->info("Record creato con ID {$record->id}");

        return self::SUCCESS;
    }

    private function deleteRecord(CloudflareDnsClient $cloudflare, string $zoneId): int
    {
        $type = strtoupper($this->option('type'));
        $name = $this->option('name') ?? $this->ask('Nome del record');

        $record = $cloudflare->find($zoneId, $type, $name);

        if ($record === null) {
            $this->components->warn("Nessun record {$type} trovato per {$name}");

            return self::FAILURE;
        }

        if (! $this->confirm("Eliminare {$type} {$name} -> {$record->content}?")) {
            return self::SUCCESS;
        }

        $cloudflare->delete($zoneId, $record->id);
        $this->components->info('Record eliminato');

        return self::SUCCESS;
    }

    private function invalidAction(): int
    {
        $this->components->error('Azione non valida: usare list, create oppure delete');

        return self::INVALID;
    }
}

Alcuni esempi di utilizzo:

php artisan cloudflare:dns list --type=CNAME
php artisan cloudflare:dns create --name=api.example.com --content=198.51.100.10 --proxied
php artisan cloudflare:dns create --type=TXT --name=_verify.example.com --content="token=abc123"
php artisan cloudflare:dns delete --name=api.example.com

DNS dinamico con un job in coda

Un caso d'uso molto comune è il DNS dinamico: un server con IP pubblico variabile (tipicamente un server domestico) deve mantenere aggiornato un record A. Con Laravel è sufficiente un job pianificato che rilevi l'IP corrente e chiami upsert(), il quale esegue la scrittura solo quando l'indirizzo è effettivamente cambiato.

<?php

namespace App\Jobs;

use App\Services\Cloudflare\CloudflareDnsClient;
use App\Services\Cloudflare\Data\DnsRecord;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class UpdateDynamicDnsRecord implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $tries = 3;

    public int $backoff = 60;

    public function __construct(public readonly string $hostname)
    {
    }

    public function uniqueId(): string
    {
        return $this->hostname;
    }

    public function handle(CloudflareDnsClient $cloudflare): void
    {
        // Rileva l'IP pubblico corrente tramite il servizio trace di Cloudflare
        $trace = Http::timeout(10)->get('https://1.1.1.1/cdn-cgi/trace')->body();
        preg_match('/^ip=(.+)$/m', $trace, $matches);
        $ip = trim($matches[1] ?? '');

        if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
            Log::warning('DDNS: impossibile determinare un IPv4 pubblico valido', ['trace' => $trace]);

            return;
        }

        $zoneId = $cloudflare->zoneId(config('services.cloudflare.default_zone'));

        $record = $cloudflare->upsert($zoneId, new DnsRecord(
            type: 'A',
            name: $this->hostname,
            content: $ip,
            ttl: 120,
            proxied: false,
            comment: 'Aggiornato automaticamente da DDNS',
        ));

        Log::info('DDNS: record allineato', ['hostname' => $this->hostname, 'ip' => $record->content]);
    }
}

La pianificazione si definisce in routes/console.php:

<?php

use App\Jobs\UpdateDynamicDnsRecord;
use Illuminate\Support\Facades\Schedule;

// Controlla ogni cinque minuti se l'IP pubblico è cambiato
Schedule::job(new UpdateDynamicDnsRecord('home.example.com'))
    ->everyFiveMinutes()
    ->withoutOverlapping();

Per un record DDNS è preferibile disattivare il proxy (proxied: false) se il record deve esporre servizi non HTTP, come SSH o una VPN, e usare un TTL basso per ridurre il tempo di propagazione del nuovo indirizzo.

Test automatizzati con Http::fake

Poiché il client usa l'HTTP client di Laravel, i test possono simulare le risposte di Cloudflare senza effettuare chiamate reali e verificare con precisione quali richieste vengono inviate. L'esempio usa Pest, ma la stessa logica vale con PHPUnit.

<?php

use App\Services\Cloudflare\CloudflareDnsClient;
use App\Services\Cloudflare\Data\DnsRecord;
use App\Services\Cloudflare\Exceptions\CloudflareException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    config()->set('services.cloudflare.token', 'test-token');
    // Impedisce qualsiasi chiamata di rete non simulata
    Http::preventStrayRequests();
});

function cloudflareResponse(mixed $result, array $resultInfo = []): array
{
    return array_filter([
        'success' => true,
        'errors' => [],
        'messages' => [],
        'result' => $result,
        'result_info' => $resultInfo,
    ], fn ($value) => $value !== []);
}

it('crea un record DNS inviando il payload corretto', function () {
    Http::fake([
        'api.cloudflare.com/client/v4/zones/zone-123/dns_records' => Http::response(cloudflareResponse([
            'id' => 'rec-1',
            'type' => 'A',
            'name' => 'api.example.com',
            'content' => '198.51.100.10',
            'ttl' => 1,
            'proxied' => true,
        ])),
    ]);

    $record = app(CloudflareDnsClient::class)->create('zone-123', new DnsRecord(
        type: 'A',
        name: 'api.example.com',
        content: '198.51.100.10',
        proxied: true,
    ));

    expect($record->id)->toBe('rec-1');

    Http::assertSent(fn (Request $request) => $request->method() === 'POST'
        && $request->hasHeader('Authorization', 'Bearer test-token')
        && $request['name'] === 'api.example.com'
        && $request['proxied'] === true);
});

it('segue la paginazione durante l\'elenco dei record', function () {
    Http::fake([
        '*/dns_records?page=1&*' => Http::response(cloudflareResponse(
            [['id' => 'a', 'type' => 'A', 'name' => 'a.example.com', 'content' => '192.0.2.1']],
            ['page' => 1, 'total_pages' => 2],
        )),
        '*/dns_records?page=2&*' => Http::response(cloudflareResponse(
            [['id' => 'b', 'type' => 'A', 'name' => 'b.example.com', 'content' => '192.0.2.2']],
            ['page' => 2, 'total_pages' => 2],
        )),
    ]);

    $ids = app(CloudflareDnsClient::class)->records('zone-123')->pluck('id')->all();

    expect($ids)->toBe(['a', 'b']);
    Http::assertSentCount(2);
});

it('non aggiorna un record il cui contenuto non è cambiato', function () {
    Http::fake([
        '*/dns_records?*' => Http::response(cloudflareResponse(
            [['id' => 'rec-1', 'type' => 'A', 'name' => 'home.example.com', 'content' => '203.0.113.5', 'proxied' => false]],
            ['page' => 1, 'total_pages' => 1],
        )),
    ]);

    app(CloudflareDnsClient::class)->upsert('zone-123', new DnsRecord(
        type: 'A',
        name: 'home.example.com',
        content: '203.0.113.5',
    ));

    // Solo la ricerca, nessuna PUT
    Http::assertSentCount(1);
    Http::assertNotSent(fn (Request $request) => $request->method() === 'PUT');
});

it('trasforma gli errori di Cloudflare in una CloudflareException', function () {
    Http::fake([
        '*' => Http::response([
            'success' => false,
            'errors' => [['code' => 81053, 'message' => 'An A, AAAA, or CNAME record with that host already exists.']],
            'messages' => [],
            'result' => null,
        ], 400),
    ]);

    app(CloudflareDnsClient::class)->create('zone-123', new DnsRecord('A', 'api.example.com', '198.51.100.10'));
})->throws(CloudflareException::class, '[81053]');

Considerazioni sulla sicurezza e sull'affidabilità

Un token in grado di modificare i record DNS è una credenziale estremamente sensibile: chi lo ottiene può reindirizzare il traffico del dominio, emettere certificati TLS validi tramite la validazione DNS e intercettare la posta elettronica modificando i record MX. Alcune regole da seguire:

  • usare sempre token con il minimo privilegio, limitati alle sole zone necessarie e, se possibile, agli IP dei server applicativi;
  • non registrare mai il token nei log: l'HTTP client di Laravel non lo fa di default, ma occorre prestare attenzione a eventuali middleware di logging personalizzati;
  • validare l'input dell'utente prima di costruire nomi di record (per esempio lo slug di un tenant) per evitare che qualcuno possa sovrascrivere record come www o mail, mantenendo una lista di nomi riservati;
  • eseguire le operazioni DNS in job asincroni, così che un rallentamento delle API non blocchi le richieste HTTP degli utenti;
  • prevedere un backup periodico della zona, che Cloudflare permette di esportare in formato BIND tramite l'endpoint GET /zones/{zone_id}/dns_records/export.

Quest'ultimo punto si implementa facilmente aggiungendo un metodo al client e salvando il file su uno dei dischi configurati in Laravel:

public function export(string $zoneId): string
{
    // L'endpoint di esportazione restituisce testo in formato BIND, non JSON
    return $this->http()
        ->accept('text/plain')
        ->get("zones/{$zoneId}/dns_records/export")
        ->body();
}
use Illuminate\Support\Facades\Storage;

$zoneId = $cloudflare->zoneId('example.com');

Storage::disk('backups')->put(
    'dns/example.com-'.now()->format('Y-m-d').'.zone',
    $cloudflare->export($zoneId),
);

Conclusioni

Le API di Cloudflare rendono la gestione DNS un'operazione programmabile a tutti gli effetti, e Laravel fornisce tutti gli strumenti per integrarla in modo pulito: l'HTTP client con retry e gestione degli errori, il service container per l'iniezione delle dipendenze, i comandi Artisan per l'amministrazione, le code e lo scheduler per le operazioni periodiche e Http::fake per testare tutto senza toccare la zona reale.

Partendo dal client presentato in questo articolo è possibile estendere l'integrazione ad altri servizi Cloudflare che seguono lo stesso modello di API, come le regole del firewall, lo svuotamento della cache o la gestione dei certificati, riutilizzando la stessa infrastruttura di autenticazione, gestione degli errori e test.