Usare le API di Cloudflare per la gestione DNS con C#

Usare le API di Cloudflare per la gestione DNS con C#

Gestire i record DNS dal pannello di Cloudflare va benissimo finché le zone sono poche e le modifiche occasionali. Quando però i record diventano parte di un processo automatizzato, come il provisioning di nuovi ambienti, il rinnovo dei certificati con la challenge DNS-01 o un aggiornamento dinamico dell'indirizzo IP di un server domestico, serve un approccio programmatico. In questo articolo vedremo come costruire in C# un client tipizzato per le API v4 di Cloudflare, capace di elencare, creare, aggiornare ed eliminare record DNS, gestendo in modo corretto paginazione, errori e limiti di frequenza.

Prerequisiti

Per seguire gli esempi servono:

  • .NET 8 o successivo (gli esempi usano JsonNamingPolicy.SnakeCaseLower, introdotto in .NET 8);
  • un dominio la cui zona DNS sia gestita da Cloudflare;
  • un API Token creato dalla sezione My Profile > API Tokens della dashboard.

Il token va creato con i permessi minimi necessari: Zone > Zone > Read per poter risalire all'identificativo della zona a partire dal nome di dominio e Zone > DNS > Edit per modificare i record. È buona pratica limitare le Zone Resources alle sole zone che l'applicazione deve effettivamente gestire. Si eviti invece la Global API Key: concede accesso completo all'account e non può essere ristretta.

Creiamo un progetto console:

dotnet new console -n CloudflareDns
cd CloudflareDns

Non sono necessari pacchetti aggiuntivi: HttpClient, System.Net.Http.Json e System.Text.Json fanno già parte del framework.

Come sono strutturate le API di Cloudflare

Tutti gli endpoint della versione 4 si trovano sotto l'URL di base https://api.cloudflare.com/client/v4/. L'autenticazione tramite token avviene con l'intestazione standard Authorization: Bearer <token>. Gli endpoint che ci interessano sono i seguenti:

  • GET user/tokens/verify: verifica che il token sia valido e attivo;
  • GET zones?name=example.com: restituisce la zona corrispondente al dominio, da cui si ricava lo zone_id;
  • GET zones/{zone_id}/dns_records: elenca i record, con filtri opzionali come type e name;
  • POST zones/{zone_id}/dns_records: crea un nuovo record;
  • PATCH zones/{zone_id}/dns_records/{record_id}: aggiorna solo i campi specificati;
  • PUT zones/{zone_id}/dns_records/{record_id}: sostituisce completamente il record;
  • DELETE zones/{zone_id}/dns_records/{record_id}: elimina il record;
  • POST zones/{zone_id}/dns_records/batch: applica più operazioni in un'unica richiesta.

Ogni risposta, indipendentemente dall'endpoint, usa lo stesso involucro JSON:

{
  "success": true,
  "errors": [],
  "messages": [],
  "result": { },
  "result_info": {
    "page": 1,
    "per_page": 100,
    "count": 3,
    "total_count": 3,
    "total_pages": 1
  }
}

Il campo result contiene un oggetto o un array a seconda dell'operazione, mentre result_info è presente solo nelle risposte paginate. In caso di errore success vale false e l'array errors contiene oggetti con un codice numerico e un messaggio. Questa uniformità ci permette di scrivere un unico metodo generico per l'invio delle richieste.

I modelli dei dati

Le API usano nomi di proprietà in snake_case. Invece di decorare ogni proprietà con [JsonPropertyName], configureremo il serializzatore con la politica SnakeCaseLower, così che ModifiedOn venga mappato automaticamente su modified_on. Creiamo il file Models.cs:

namespace CloudflareDns;

// Involucro comune a tutte le risposte delle API v4
public sealed class CloudflareResponse<T>
{
    public bool Success { get; init; }
    public List<CloudflareMessage> Errors { get; init; } = [];
    public List<CloudflareMessage> Messages { get; init; } = [];
    public T? Result { get; init; }
    public ResultInfo? ResultInfo { get; init; }
}

public sealed class CloudflareMessage
{
    public int Code { get; init; }
    public string Message { get; init; } = string.Empty;

    public override string ToString() => $"[{Code}] {Message}";
}

// Informazioni di paginazione
public sealed class ResultInfo
{
    public int Page { get; init; }
    public int PerPage { get; init; }
    public int Count { get; init; }
    public int TotalCount { get; init; }
    public int TotalPages { get; init; }
}

public sealed class Zone
{
    public string Id { get; init; } = string.Empty;
    public string Name { get; init; } = string.Empty;
    public string Status { get; init; } = string.Empty;
}

public sealed class TokenStatus
{
    public string Id { get; init; } = string.Empty;
    public string Status { get; init; } = string.Empty;
}

// Record DNS così come restituito dalle API
public sealed class DnsRecord
{
    public string Id { get; init; } = string.Empty;
    public string Type { get; init; } = string.Empty;
    public string Name { get; init; } = string.Empty;
    public string Content { get; init; } = string.Empty;
    public int Ttl { get; init; }
    public bool Proxied { get; init; }
    public bool Proxiable { get; init; }
    public int? Priority { get; init; }
    public string? Comment { get; init; }
    public DateTimeOffset CreatedOn { get; init; }
    public DateTimeOffset ModifiedOn { get; init; }

    public override string ToString() =>
        $"{Type,-6} {Name,-35} {Content,-40} ttl={(Ttl == 1 ? "auto" : Ttl.ToString())} proxied={Proxied}";
}

// Dati inviati in creazione o sostituzione completa (POST e PUT)
public sealed record DnsRecordInput
{
    public required string Type { get; init; }
    public required string Name { get; init; }
    public required string Content { get; init; }

    // Il valore 1 indica il TTL automatico gestito da Cloudflare
    public int Ttl { get; init; } = 1;
    public bool? Proxied { get; init; }
    public int? Priority { get; init; }
    public string? Comment { get; init; }
}

// Dati inviati in aggiornamento parziale (PATCH): solo i campi non nulli vengono serializzati
public sealed record DnsRecordPatch
{
    public string? Type { get; init; }
    public string? Name { get; init; }
    public string? Content { get; init; }
    public int? Ttl { get; init; }
    public bool? Proxied { get; init; }
    public int? Priority { get; init; }
    public string? Comment { get; init; }
}

La distinzione tra DnsRecordInput e DnsRecordPatch non è solo formale. Una richiesta PUT sostituisce l'intero record e richiede quindi tutti i campi obbligatori, espressi qui con il modificatore required. Una richiesta PATCH modifica invece soltanto i campi presenti nel corpo: rendendo tutte le proprietà nullable e ignorando i valori nulli in serializzazione, possiamo aggiornare per esempio il solo content senza toccare TTL e proxy.

Il campo Priority è rilevante solo per record di tipo MX, SRV e URI, mentre Proxied ha senso solo per i tipi che possono passare attraverso il proxy di Cloudflare, cioè A, AAAA e CNAME. Quando un record è in modalità proxy il TTL viene sempre gestito automaticamente.

Un'eccezione dedicata

Per rendere gli errori facili da gestire nel codice chiamante, definiamo un'eccezione che conservi il codice di stato HTTP e l'elenco degli errori restituiti da Cloudflare. File CloudflareApiException.cs:

using System.Net;

namespace CloudflareDns;

public sealed class CloudflareApiException : Exception
{
    public HttpStatusCode StatusCode { get; }
    public IReadOnlyList<CloudflareMessage> Errors { get; }

    public CloudflareApiException(HttpStatusCode statusCode, IReadOnlyList<CloudflareMessage> errors, string? rawBody = null)
        : base(BuildMessage(statusCode, errors, rawBody))
    {
        StatusCode = statusCode;
        Errors = errors;
    }

    // Verifica se tra gli errori è presente un determinato codice Cloudflare
    public bool HasErrorCode(int code) => Errors.Any(e => e.Code == code);

    private static string BuildMessage(HttpStatusCode statusCode, IReadOnlyList<CloudflareMessage> errors, string? rawBody)
    {
        if (errors.Count > 0)
        {
            return $"Cloudflare API error (HTTP {(int)statusCode}): {string.Join("; ", errors)}";
        }

        // Nessun errore strutturato: si riporta un estratto del corpo grezzo
        var excerpt = rawBody is { Length: > 200 } ? rawBody[..200] + "..." : rawBody;
        return $"Cloudflare API error (HTTP {(int)statusCode}): {excerpt}";
    }
}

Gestire i limiti di frequenza

Le API di Cloudflare applicano un limite globale al numero di richieste per utente in una finestra di cinque minuti. Superato il limite, le chiamate ricevono una risposta 429 Too Many Requests, spesso accompagnata dall'intestazione Retry-After. Anche errori transitori come 502 o 503 meritano un nuovo tentativo. Invece di spargere questa logica nel client, la isoliamo in un DelegatingHandler che si inserisce nella pipeline di HttpClient. File RetryHandler.cs:

using System.Net;

namespace CloudflareDns;

public sealed class RetryHandler : DelegatingHandler
{
    private readonly int _maxRetries;
    private readonly TimeSpan _baseDelay;

    public RetryHandler(int maxRetries = 4, TimeSpan? baseDelay = null)
    {
        _maxRetries = maxRetries;
        _baseDelay = baseDelay ?? TimeSpan.FromSeconds(1);
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        for (var attempt = 0; ; attempt++)
        {
            var response = await base.SendAsync(request, cancellationToken);

            if (!IsTransient(response.StatusCode) || attempt >= _maxRetries)
            {
                return response;
            }

            var delay = GetDelay(response, attempt);
            response.Dispose();

            await Task.Delay(delay, cancellationToken);
        }
    }

    private static bool IsTransient(HttpStatusCode status) =>
        status is HttpStatusCode.TooManyRequests
            or HttpStatusCode.BadGateway
            or HttpStatusCode.ServiceUnavailable
            or HttpStatusCode.GatewayTimeout;

    private TimeSpan GetDelay(HttpResponseMessage response, int attempt)
    {
        // Se il server indica quanto attendere, si rispetta la sua indicazione
        var retryAfter = response.Headers.RetryAfter;
        if (retryAfter?.Delta is { } delta)
        {
            return delta;
        }
        if (retryAfter?.Date is { } date)
        {
            var wait = date - DateTimeOffset.UtcNow;
            if (wait > TimeSpan.Zero)
            {
                return wait;
            }
        }

        // Altrimenti backoff esponenziale con una componente casuale (jitter)
        var exponential = _baseDelay.TotalMilliseconds * Math.Pow(2, attempt);
        var jitter = Random.Shared.Next(0, 250);
        return TimeSpan.FromMilliseconds(exponential + jitter);
    }
}

Il corpo delle richieste viene creato con JsonContent, che serializza l'oggetto a ogni invio: per questo è possibile ripetere la stessa richiesta più volte all'interno dell'handler. In un'applicazione ASP.NET Core o in un worker service si può in alternativa ricorrere al pacchetto Microsoft.Extensions.Http.Resilience, che offre strategie di retry, circuit breaker e timeout già pronte.

Il client DNS

Arriviamo al cuore dell'implementazione. La classe CloudflareDnsClient riceve un HttpClient già configurato con indirizzo di base e intestazione di autenticazione, e offre metodi asincroni per tutte le operazioni. File CloudflareDnsClient.cs:

using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace CloudflareDns;

public sealed class CloudflareDnsClient
{
    public const string BaseUrl = "https://api.cloudflare.com/client/v4/";

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
        PropertyNameCaseInsensitive = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    private readonly HttpClient _http;

    public CloudflareDnsClient(HttpClient http)
    {
        _http = http;
    }

    // Crea un HttpClient pronto all'uso con token e gestione dei retry
    public static HttpClient CreateHttpClient(string apiToken)
    {
        var handler = new RetryHandler { InnerHandler = new SocketsHttpHandler() };

        var http = new HttpClient(handler)
        {
            BaseAddress = new Uri(BaseUrl),
            Timeout = TimeSpan.FromSeconds(30)
        };

        http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
        http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
        http.DefaultRequestHeaders.UserAgent.ParseAdd("CloudflareDns/1.0");

        return http;
    }

    // Metodo generico che invia la richiesta e interpreta l'involucro della risposta
    private async Task<CloudflareResponse<T>> SendAsync<T>(
        HttpMethod method,
        string path,
        object? body,
        CancellationToken ct)
    {
        using var request = new HttpRequestMessage(method, path);

        if (body is not null)
        {
            request.Content = JsonContent.Create(body, body.GetType(), options: JsonOptions);
        }

        using var response = await _http.SendAsync(request, ct);
        var raw = await response.Content.ReadAsStringAsync(ct);

        CloudflareResponse<T>? payload = null;
        try
        {
            payload = JsonSerializer.Deserialize<CloudflareResponse<T>>(raw, JsonOptions);
        }
        catch (JsonException)
        {
            // Il corpo non è JSON (per esempio una pagina di errore HTML di un proxy)
        }

        if (payload is null)
        {
            throw new CloudflareApiException(response.StatusCode, [], raw);
        }

        if (!response.IsSuccessStatusCode || !payload.Success)
        {
            throw new CloudflareApiException(response.StatusCode, payload.Errors, raw);
        }

        return payload;
    }

    // Verifica la validità del token
    public async Task<bool> VerifyTokenAsync(CancellationToken ct = default)
    {
        var response = await SendAsync<TokenStatus>(HttpMethod.Get, "user/tokens/verify", null, ct);
        return string.Equals(response.Result?.Status, "active", StringComparison.OrdinalIgnoreCase);
    }

    // Restituisce l'identificativo della zona a partire dal nome di dominio
    public async Task<string> GetZoneIdAsync(string domain, CancellationToken ct = default)
    {
        var path = $"zones?name={Uri.EscapeDataString(domain)}";
        var response = await SendAsync<List<Zone>>(HttpMethod.Get, path, null, ct);

        var zone = response.Result?.FirstOrDefault()
            ?? throw new InvalidOperationException($"Zone '{domain}' not found or not accessible with this token.");

        return zone.Id;
    }

    // Elenca i record DNS della zona, gestendo la paginazione in modo trasparente
    public async IAsyncEnumerable<DnsRecord> ListRecordsAsync(
        string zoneId,
        string? type = null,
        string? name = null,
        int perPage = 100,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
    {
        var page = 1;

        while (true)
        {
            var query = new List<string> { $"page={page}", $"per_page={perPage}" };
            if (!string.IsNullOrWhiteSpace(type))
            {
                query.Add($"type={Uri.EscapeDataString(type)}");
            }
            if (!string.IsNullOrWhiteSpace(name))
            {
                query.Add($"name={Uri.EscapeDataString(name)}");
            }

            var path = $"zones/{zoneId}/dns_records?{string.Join("&", query)}";
            var response = await SendAsync<List<DnsRecord>>(HttpMethod.Get, path, null, ct);

            foreach (var record in response.Result ?? [])
            {
                yield return record;
            }

            var info = response.ResultInfo;
            if (info is null || page >= info.TotalPages)
            {
                yield break;
            }

            page++;
        }
    }

    // Restituisce un singolo record dato il suo identificativo
    public async Task<DnsRecord> GetRecordAsync(string zoneId, string recordId, CancellationToken ct = default)
    {
        var response = await SendAsync<DnsRecord>(HttpMethod.Get, $"zones/{zoneId}/dns_records/{recordId}", null, ct);
        return response.Result!;
    }

    // Crea un nuovo record
    public async Task<DnsRecord> CreateRecordAsync(string zoneId, DnsRecordInput input, CancellationToken ct = default)
    {
        var response = await SendAsync<DnsRecord>(HttpMethod.Post, $"zones/{zoneId}/dns_records", input, ct);
        return response.Result!;
    }

    // Aggiorna solo i campi indicati
    public async Task<DnsRecord> PatchRecordAsync(string zoneId, string recordId, DnsRecordPatch patch, CancellationToken ct = default)
    {
        var response = await SendAsync<DnsRecord>(HttpMethod.Patch, $"zones/{zoneId}/dns_records/{recordId}", patch, ct);
        return response.Result!;
    }

    // Sostituisce completamente il record
    public async Task<DnsRecord> ReplaceRecordAsync(string zoneId, string recordId, DnsRecordInput input, CancellationToken ct = default)
    {
        var response = await SendAsync<DnsRecord>(HttpMethod.Put, $"zones/{zoneId}/dns_records/{recordId}", input, ct);
        return response.Result!;
    }

    // Elimina il record
    public async Task DeleteRecordAsync(string zoneId, string recordId, CancellationToken ct = default)
    {
        await SendAsync<JsonElement>(HttpMethod.Delete, $"zones/{zoneId}/dns_records/{recordId}", null, ct);
    }
}

Alcuni dettagli meritano attenzione. Il primo riguarda l'indirizzo di base: BaseUrl termina con una barra e tutti i percorsi relativi sono scritti senza barra iniziale. Se si scrivesse /zones, la risoluzione dell'URI scarterebbe il segmento /client/v4 e la richiesta finirebbe su https://api.cloudflare.com/zones, con un errore 404 difficile da diagnosticare.

Il secondo riguarda la lettura della risposta. Il corpo viene letto come stringa prima della deserializzazione: in questo modo, se un proxy intermedio restituisce una pagina HTML invece del JSON atteso, l'eccezione conterrà un estratto del contenuto reale anziché un generico errore di parsing.

Il terzo riguarda ListRecordsAsync, che restituisce un IAsyncEnumerable<DnsRecord>. Il chiamante può iterare con await foreach senza preoccuparsi delle pagine: le richieste successive vengono eseguite solo quando servono, e se il ciclo si interrompe in anticipo non vengono scaricate pagine inutili. L'attributo EnumeratorCancellation consente di propagare il token di cancellazione anche quando viene passato tramite WithCancellation.

Infine, i filtri type e name vengono applicati lato server. Il parametro name deve contenere il nome completo del record, per esempio www.example.com e non solo www.

Creare o aggiornare: il metodo upsert

Nella maggior parte degli scenari di automazione non interessa sapere se un record esiste già: si vuole semplicemente che, al termine dell'operazione, il record abbia un determinato valore. Implementiamo quindi un metodo upsert idempotente come metodo di estensione, in un file CloudflareDnsClientExtensions.cs:

namespace CloudflareDns;

public enum UpsertOutcome
{
    Created,
    Updated,
    Unchanged
}

public static class CloudflareDnsClientExtensions
{
    // Garantisce che esista un record con il tipo, il nome e il contenuto indicati
    public static async Task<(DnsRecord Record, UpsertOutcome Outcome)> UpsertRecordAsync(
        this CloudflareDnsClient client,
        string zoneId,
        DnsRecordInput input,
        CancellationToken ct = default)
    {
        DnsRecord? existing = null;

        await foreach (var record in client.ListRecordsAsync(zoneId, input.Type, input.Name, ct: ct))
        {
            existing = record;
            break;
        }

        if (existing is null)
        {
            var created = await client.CreateRecordAsync(zoneId, input, ct);
            return (created, UpsertOutcome.Created);
        }

        var needsUpdate =
            !string.Equals(existing.Content, input.Content, StringComparison.OrdinalIgnoreCase)
            || existing.Ttl != input.Ttl
            || (input.Proxied is { } proxied && existing.Proxied != proxied)
            || (input.Priority is { } priority && existing.Priority != priority);

        if (!needsUpdate)
        {
            return (existing, UpsertOutcome.Unchanged);
        }

        var patch = new DnsRecordPatch
        {
            Content = input.Content,
            Ttl = input.Ttl,
            Proxied = input.Proxied,
            Priority = input.Priority,
            Comment = input.Comment
        };

        var updated = await client.PatchRecordAsync(zoneId, existing.Id, patch, ct);
        return (updated, UpsertOutcome.Updated);
    }
}

Il metodo confronta lo stato attuale con quello desiderato ed evita di inviare modifiche quando non servono. Questo riduce il numero di chiamate, fattore importante quando lo script gira a intervalli regolari, e rende i log più leggibili perché segnalano solo i cambiamenti reali.

Va tenuto presente che per alcuni tipi di record, come TXT o MX, è normale avere più record con lo stesso nome. In quei casi l'upsert basato sulla sola coppia tipo e nome non è adatto: bisogna filtrare anche per contenuto, oppure gestire l'insieme dei record come un tutt'uno, operazione per cui è più indicato l'endpoint batch che vedremo tra poco.

Un esempio completo: aggiornamento DNS dinamico

Mettiamo insieme i pezzi con uno scenario concreto e molto diffuso: mantenere aggiornato un record A che punta a un server domestico con indirizzo IP pubblico variabile. Il programma legge il token da una variabile d'ambiente, rileva l'IP pubblico corrente e aggiorna il record solo se è cambiato. File Program.cs:

using CloudflareDns;

// Lettura della configurazione da variabili d'ambiente e argomenti
var apiToken = Environment.GetEnvironmentVariable("CLOUDFLARE_API_TOKEN");
if (string.IsNullOrWhiteSpace(apiToken))
{
    Console.Error.WriteLine("Missing CLOUDFLARE_API_TOKEN environment variable.");
    return 1;
}

if (args.Length < 2)
{
    Console.Error.WriteLine("Usage: CloudflareDns <zone> <record-name> [--proxied]");
    return 1;
}

var zoneName = args[0];
var recordName = args[1];
var proxied = args.Contains("--proxied");

// Cancellazione pulita con Ctrl+C
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
    e.Cancel = true;
    cts.Cancel();
};

using var http = CloudflareDnsClient.CreateHttpClient(apiToken);
var client = new CloudflareDnsClient(http);

try
{
    if (!await client.VerifyTokenAsync(cts.Token))
    {
        Console.Error.WriteLine("The API token is not active.");
        return 1;
    }

    var zoneId = await client.GetZoneIdAsync(zoneName, cts.Token);
    Console.WriteLine($"Zone {zoneName} -> {zoneId}");

    var publicIp = await GetPublicIpAsync(cts.Token);
    Console.WriteLine($"Current public IP: {publicIp}");

    var (record, outcome) = await client.UpsertRecordAsync(zoneId, new DnsRecordInput
    {
        Type = "A",
        Name = recordName,
        Content = publicIp,
        Ttl = proxied ? 1 : 300,
        Proxied = proxied,
        Comment = $"Updated by CloudflareDns on {DateTimeOffset.UtcNow:u}"
    }, cts.Token);

    Console.WriteLine($"{outcome}: {record}");

    // Elenco di tutti i record della zona a scopo di verifica
    Console.WriteLine();
    Console.WriteLine("Records in zone:");
    await foreach (var r in client.ListRecordsAsync(zoneId, ct: cts.Token))
    {
        Console.WriteLine($"  {r}");
    }

    return 0;
}
catch (CloudflareApiException ex)
{
    Console.Error.WriteLine(ex.Message);
    return 2;
}
catch (OperationCanceledException)
{
    Console.Error.WriteLine("Operation cancelled.");
    return 130;
}

// Rileva l'indirizzo IPv4 pubblico tramite un servizio esterno
static async Task<string> GetPublicIpAsync(CancellationToken ct)
{
    using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
    var ip = (await http.GetStringAsync("https://api.ipify.org", ct)).Trim();

    if (!System.Net.IPAddress.TryParse(ip, out var address)
        || address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
    {
        throw new InvalidOperationException($"Unexpected response from IP service: '{ip}'");
    }

    return ip;
}

Per eseguirlo:

export CLOUDFLARE_API_TOKEN="il-tuo-token"
dotnet run -- example.com home.example.com

L'output avrà una forma simile a questa:

Zone example.com -> 023e105f4ecef8ad9ca31a8372d0c353
Current public IP: 203.0.113.42
Updated: A      home.example.com                    203.0.113.42                             ttl=300 proxied=False

Records in zone:
  A      example.com                         198.51.100.10                            ttl=auto proxied=True
  A      home.example.com                    203.0.113.42                             ttl=300 proxied=False
  CNAME  www.example.com                     example.com                              ttl=auto proxied=True
  MX     example.com                         mail.example.com                         ttl=3600 proxied=False

Pianificando l'esecuzione con un timer di systemd o con cron ogni cinque minuti si ottiene un servizio di DNS dinamico completo, senza dipendere da provider esterni. Grazie al controllo di idempotenza, nella quasi totalità delle esecuzioni il programma si limiterà a letture e non modificherà nulla.

Operazioni multiple con l'endpoint batch

Quando occorre applicare molte modifiche insieme, per esempio durante la migrazione di un servizio da un gruppo di server a un altro, inviare una richiesta per record è lento e consuma rapidamente il limite di frequenza. L'endpoint dns_records/batch accetta in un'unica chiamata quattro liste di operazioni: deletes, patches, puts e posts. Le operazioni vengono eseguite in quest'ordine e trattate come un'unica transazione: se una fallisce, nessuna modifica viene applicata.

Aggiungiamo i modelli necessari a Models.cs:

// Riferimento a un record da eliminare
public sealed record DnsRecordRef(string Id);

// Aggiornamento parziale con identificativo, usato nelle patch del batch
public sealed record DnsRecordBatchPatch
{
    public required string Id { get; init; }
    public string? Content { get; init; }
    public int? Ttl { get; init; }
    public bool? Proxied { get; init; }
    public string? Comment { get; init; }
}

// Corpo della richiesta batch
public sealed class DnsBatchRequest
{
    public List<DnsRecordRef>? Deletes { get; init; }
    public List<DnsRecordBatchPatch>? Patches { get; init; }
    public List<DnsRecordInput>? Posts { get; init; }
}

// Risultato del batch, suddiviso per tipo di operazione
public sealed class DnsBatchResult
{
    public List<DnsRecord> Deletes { get; init; } = [];
    public List<DnsRecord> Patches { get; init; } = [];
    public List<DnsRecord> Puts { get; init; } = [];
    public List<DnsRecord> Posts { get; init; } = [];
}

E il relativo metodo in CloudflareDnsClient:

// Applica più operazioni in modo atomico
public async Task<DnsBatchResult> BatchAsync(string zoneId, DnsBatchRequest batch, CancellationToken ct = default)
{
    var response = await SendAsync<DnsBatchResult>(HttpMethod.Post, $"zones/{zoneId}/dns_records/batch", batch, ct);
    return response.Result!;
}

Ecco come usarlo per sostituire in blocco tutti i record A di un nome con un nuovo insieme di indirizzi, uno scenario tipico del bilanciamento del carico tramite DNS round robin:

// Sostituisce l'insieme dei record A associati a un nome
static async Task ReplaceARecordsAsync(
    CloudflareDnsClient client,
    string zoneId,
    string name,
    IReadOnlyCollection<string> newAddresses,
    CancellationToken ct)
{
    var current = new List<DnsRecord>();
    await foreach (var record in client.ListRecordsAsync(zoneId, "A", name, ct: ct))
    {
        current.Add(record);
    }

    var desired = newAddresses.ToHashSet();

    // Si eliminano solo i record non più desiderati
    var deletes = current
        .Where(r => !desired.Contains(r.Content))
        .Select(r => new DnsRecordRef(r.Id))
        .ToList();

    // Si creano solo gli indirizzi mancanti
    var existingContents = current.Select(r => r.Content).ToHashSet();
    var posts = desired
        .Where(ip => !existingContents.Contains(ip))
        .Select(ip => new DnsRecordInput { Type = "A", Name = name, Content = ip, Ttl = 60 })
        .ToList();

    if (deletes.Count == 0 && posts.Count == 0)
    {
        Console.WriteLine("Nothing to change.");
        return;
    }

    var result = await client.BatchAsync(zoneId, new DnsBatchRequest
    {
        Deletes = deletes.Count > 0 ? deletes : null,
        Posts = posts.Count > 0 ? posts : null
    }, ct);

    Console.WriteLine($"Deleted: {result.Deletes.Count}, created: {result.Posts.Count}");
}

Il vantaggio dell'atomicità è evidente: non esiste mai un istante intermedio in cui il nome risolve su un insieme incompleto o, peggio, su nessun indirizzo, come potrebbe accadere eseguendo eliminazioni e creazioni con richieste separate.

Gestione degli errori

Grazie a CloudflareApiException il codice chiamante può distinguere i diversi casi di errore. I più comuni sono:

  • 401 o 403: token mancante, scaduto o privo dei permessi necessari. Si tratta di un errore di configurazione, da non ripetere automaticamente;
  • 400 con errori di validazione: contenuto non valido per il tipo di record (per esempio un indirizzo IPv6 in un record A), TTL fuori intervallo, conflitto con un CNAME esistente sullo stesso nome;
  • 404: zona o record inesistente, oppure non visibile con il token in uso;
  • 429: limite di frequenza superato, gestito dal RetryHandler.

Un pattern utile è trasformare gli errori di validazione in messaggi comprensibili senza interrompere un'elaborazione più ampia:

foreach (var input in recordsToSync)
{
    try
    {
        var (record, outcome) = await client.UpsertRecordAsync(zoneId, input, ct);
        Console.WriteLine($"{outcome,-10} {record.Type} {record.Name}");
    }
    catch (CloudflareApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.BadRequest)
    {
        // Errore sul singolo record: lo si segnala e si prosegue con i successivi
        Console.Error.WriteLine($"Skipped {input.Type} {input.Name}: {string.Join("; ", ex.Errors)}");
    }
}

Gli errori di autenticazione, invece, non vengono intercettati dal filtro when e risalgono fino al gestore di livello superiore: non ha senso proseguire con gli altri record se il token non è valido.

Integrazione con la dependency injection

In un'applicazione ASP.NET Core o in un worker service conviene registrare il client tramite IHttpClientFactory, che gestisce il ciclo di vita delle connessioni ed evita i problemi di esaurimento dei socket o di DNS non aggiornato tipici di un HttpClient creato e distrutto a ogni utilizzo:

builder.Services.AddTransient<RetryHandler>();

builder.Services
    .AddHttpClient<CloudflareDnsClient>(http =>
    {
        var token = builder.Configuration["Cloudflare:ApiToken"]
            ?? throw new InvalidOperationException("Missing Cloudflare:ApiToken configuration.");

        http.BaseAddress = new Uri(CloudflareDnsClient.BaseUrl);
        http.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
    })
    .AddHttpMessageHandler<RetryHandler>();

Il token andrebbe conservato con gli strumenti di gestione dei segreti dell'ambiente di esecuzione: dotnet user-secrets in sviluppo, variabili d'ambiente o un secret manager in produzione. Non va mai inserito in appsettings.json sotto controllo di versione.

Buone pratiche

  • Privilegio minimo: un token per applicazione, limitato alle sole zone e ai soli permessi necessari, con eventuale restrizione sugli indirizzi IP di provenienza e una data di scadenza;
  • Memorizzare lo zone_id: l'identificativo di una zona non cambia, quindi può essere salvato in configurazione o in cache evitando una chiamata a ogni esecuzione;
  • Idempotenza: confrontare sempre lo stato attuale con quello desiderato prima di scrivere, come fa il metodo upsert;
  • Usare il campo comment: annotare i record gestiti automaticamente aiuta chi consulta la dashboard a capire da dove provengono e a non modificarli a mano;
  • Preferire PATCH a PUT quando si modifica un solo campo, per non sovrascrivere accidentalmente impostazioni come proxy o commenti;
  • Raggruppare le modifiche con l'endpoint batch quando sono numerose o devono essere applicate in modo coerente.

Conclusioni

Con poche centinaia di righe di codice e senza dipendenze esterne abbiamo costruito un client C# tipizzato per la gestione DNS su Cloudflare: interpreta in modo uniforme l'involucro delle risposte, espone la paginazione come un flusso asincrono, ripete automaticamente le richieste in caso di limiti di frequenza o errori transitori e offre operazioni di livello più alto, come l'upsert idempotente e la sostituzione atomica di gruppi di record. Da questa base è semplice estendere il client ad altri ambiti delle API di Cloudflare, come le regole di cache, i Worker o la gestione dei certificati, riutilizzando lo stesso metodo generico di invio e la stessa gestione degli errori.