Usare le API di Cloudflare per la gestione DNS con Go

Usare le API di Cloudflare per la gestione DNS con Go

Gestire i record DNS dal pannello di Cloudflare va benissimo finché i domini sono pochi e le modifiche rare. Quando però i record vanno creati durante un deploy, aggiornati da un server con IP dinamico o sincronizzati con un inventario di macchine, l'interfaccia web diventa un collo di bottiglia. Cloudflare espone un'API REST completa (la versione 4) che permette di fare tutto ciò che si fa dal pannello, e Go è uno strumento ideale per costruirci sopra: libreria standard ricca, binari statici facili da distribuire e un ottimo supporto per la concorrenza e i timeout tramite context.

In questo articolo costruiremo da zero un piccolo pacchetto Go per la gestione dei record DNS su Cloudflare, usando esclusivamente la libreria standard. Vedremo l'autenticazione tramite token, il formato delle risposte, la paginazione, la gestione degli errori e dei limiti di frequenza, un'operazione di upsert idempotente, le operazioni batch e, infine, una CLI completa che include un client DNS dinamico.

Perché non usare direttamente l'SDK ufficiale

Cloudflare mantiene un SDK Go ufficiale (github.com/cloudflare/cloudflare-go), generato automaticamente a partire dalla specifica OpenAPI. È una scelta valida in molti contesti, ma copre l'intera superficie dell'API ed è soggetto a cambiamenti di interfaccia tra una versione maggiore e l'altra. Se l'obiettivo è limitato alla gestione del DNS, un client scritto a mano di poche centinaia di righe offre alcuni vantaggi concreti: nessuna dipendenza esterna, pieno controllo su retry e timeout e un'interfaccia modellata esattamente sui casi d'uso del proprio progetto. È anche il modo migliore per capire come funziona davvero l'API.

Creare un token API

Cloudflare supporta due meccanismi di autenticazione: la vecchia Global API Key (associata all'intero account, da evitare) e gli API Token, che hanno permessi granulari e possono essere limitati a zone specifiche. Useremo esclusivamente questi ultimi.

Dal pannello di Cloudflare si accede a My Profile → API Tokens → Create Token e si parte dal modello Edit zone DNS. I permessi necessari per il codice di questo articolo sono:

  • Zone → DNS → Edit, per leggere e modificare i record;
  • Zone → Zone → Read, per risolvere il nome di dominio nell'identificativo della zona.

Nella sezione Zone Resources conviene includere solo le zone effettivamente gestite, e se il token verrà usato da un server con IP fisso è possibile restringerne l'utilizzo a quell'indirizzo tramite Client IP Address Filtering. Il token viene mostrato una sola volta: lo salviamo in una variabile d'ambiente.

export CLOUDFLARE_API_TOKEN="il-tuo-token"

# verifica rapida del token con curl
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

Anatomia dell'API

Tutti gli endpoint condividono la base https://api.cloudflare.com/client/v4. Quelli che ci interessano sono pochi:

  • GET /user/tokens/verify: verifica la validità del token;
  • GET /zones?name=example.com: cerca una zona per nome;
  • GET /zones/{zone_id}/dns_records: elenca i record, con filtri e paginazione;
  • POST /zones/{zone_id}/dns_records: crea un record;
  • PATCH /zones/{zone_id}/dns_records/{record_id}: modifica parzialmente un record;
  • PUT /zones/{zone_id}/dns_records/{record_id}: sostituisce completamente un record;
  • DELETE /zones/{zone_id}/dns_records/{record_id}: elimina un record;
  • POST /zones/{zone_id}/dns_records/batch: esegue più operazioni in un'unica transazione.

Ogni risposta, di successo o di errore, è racchiusa nello stesso involucro JSON. Il campo result contiene il dato vero e proprio (un oggetto o un array), mentre result_info compare solo nelle risposte paginate:

{
  "success": true,
  "errors": [],
  "messages": [],
  "result": [
    {
      "id": "372e67954025e0ba6aaa6d586b9e0b59",
      "type": "A",
      "name": "www.example.com",
      "content": "198.51.100.4",
      "proxiable": true,
      "proxied": true,
      "ttl": 1,
      "comment": "Web server",
      "tags": [],
      "created_on": "2026-01-01T05:20:00.12345Z",
      "modified_on": "2026-01-01T05:20:00.12345Z"
    }
  ],
  "result_info": {
    "page": 1,
    "per_page": 100,
    "count": 1,
    "total_count": 1,
    "total_pages": 1
  }
}

Due dettagli da tenere a mente: il valore ttl: 1 significa automatico (ed è l'unico ammesso per i record proxati), e il campo name è sempre restituito come nome di dominio completo, anche se in fase di creazione si è passato solo il sottodominio.

Struttura del progetto

Organizziamo il codice in un pacchetto riutilizzabile, cfdns, e in un comando che lo utilizza:

cfdns/
├── go.mod
├── cfdns/
│   ├── client.go
│   ├── types.go
│   ├── request.go
│   ├── token.go
│   ├── zones.go
│   ├── records.go
│   ├── mutations.go
│   ├── upsert.go
│   ├── batch.go
│   └── client_test.go
└── cmd/
    └── cfdns/
        └── main.go
mkdir cfdns && cd cfdns
go mod init example.com/cfdns

È sufficiente Go 1.22 o successivo, dato che useremo i generics e alcune funzioni recenti della libreria standard.

Il client

Il client conserva l'URL di base, il token, un *http.Client e il numero massimo di tentativi in caso di errori temporanei. Per la configurazione usiamo il pattern delle functional options, che permette di aggiungere parametri in futuro senza rompere la firma del costruttore e, soprattutto, di puntare il client verso un server di test.

package cfdns

import (
	"errors"
	"net/http"
	"strings"
	"time"
)

const defaultBaseURL = "https://api.cloudflare.com/client/v4"

// Client incapsula le chiamate autenticate verso l'API v4 di Cloudflare.
type Client struct {
	baseURL    string
	token      string
	httpClient *http.Client
	maxRetries int
}

// Option permette di personalizzare il client in fase di creazione.
type Option func(*Client)

// WithBaseURL sostituisce l'endpoint predefinito (utile soprattutto nei test).
func WithBaseURL(u string) Option {
	return func(c *Client) {
		c.baseURL = strings.TrimRight(u, "/")
	}
}

// WithHTTPClient consente di fornire un client HTTP con trasporto o timeout personalizzati.
func WithHTTPClient(h *http.Client) Option {
	return func(c *Client) {
		c.httpClient = h
	}
}

// WithMaxRetries imposta il numero massimo di nuovi tentativi per gli errori temporanei.
func WithMaxRetries(n int) Option {
	return func(c *Client) {
		if n >= 0 {
			c.maxRetries = n
		}
	}
}

// NewClient crea un client autenticato con un API Token.
func NewClient(token string, opts ...Option) (*Client, error) {
	if strings.TrimSpace(token) == "" {
		return nil, errors.New("cfdns: empty API token")
	}

	c := &Client{
		baseURL:    defaultBaseURL,
		token:      token,
		httpClient: &http.Client{Timeout: 30 * time.Second},
		maxRetries: 3,
	}
	for _, opt := range opts {
		opt(c)
	}
	return c, nil
}

Modellare l'involucro delle risposte

Poiché la struttura esterna delle risposte è sempre la stessa e cambia solo il tipo di result, un tipo generico è la soluzione naturale. Definiamo inoltre un tipo di errore dedicato che conservi sia il codice HTTP sia l'elenco degli errori restituiti da Cloudflare, così che il chiamante possa ispezionarlo con errors.As.

package cfdns

import (
	"fmt"
	"strings"
)

// APIMessage rappresenta un elemento degli array errors e messages.
type APIMessage struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

// ResultInfo contiene i metadati di paginazione.
type ResultInfo struct {
	Page       int `json:"page"`
	PerPage    int `json:"per_page"`
	Count      int `json:"count"`
	TotalCount int `json:"total_count"`
	TotalPages int `json:"total_pages"`
}

// envelope è l'involucro comune a tutte le risposte dell'API.
type envelope[T any] struct {
	Success    bool         `json:"success"`
	Errors     []APIMessage `json:"errors"`
	Messages   []APIMessage `json:"messages"`
	Result     T            `json:"result"`
	ResultInfo *ResultInfo  `json:"result_info"`
}

// APIError viene restituito quando Cloudflare risponde con success=false
// oppure con uno stato HTTP di errore.
type APIError struct {
	StatusCode int
	Errors     []APIMessage
}

func (e *APIError) Error() string {
	if len(e.Errors) == 0 {
		return fmt.Sprintf("cfdns: HTTP %d", e.StatusCode)
	}
	parts := make([]string, 0, len(e.Errors))
	for _, m := range e.Errors {
		parts = append(parts, fmt.Sprintf("[%d] %s", m.Code, m.Message))
	}
	return fmt.Sprintf("cfdns: HTTP %d: %s", e.StatusCode, strings.Join(parts, "; "))
}

// HasCode indica se tra gli errori restituiti compare il codice indicato.
func (e *APIError) HasCode(code int) bool {
	for _, m := range e.Errors {
		if m.Code == code {
			return true
		}
	}
	return false
}

Eseguire le richieste: retry e limiti di frequenza

Il cuore del pacchetto è una singola funzione generica che costruisce la richiesta, aggiunge l'intestazione Authorization, decodifica l'involucro e trasforma gli errori applicativi in *APIError. Poiché in Go i metodi non possono avere parametri di tipo, la implementiamo come funzione di pacchetto che riceve il client come argomento.

Cloudflare applica un limite globale di richieste per utente (nell'ordine di 1200 richieste ogni cinque minuti) e, quando viene superato, risponde con 429 Too Many Requests. In quel caso ha senso attendere e riprovare, rispettando l'eventuale intestazione Retry-After. Anche gli errori 5xx sono in genere temporanei, ma qui serve cautela: ripetere una POST dopo un errore del server rischia di creare un record duplicato, perché la prima richiesta potrebbe essere stata elaborata comunque. Per questo ritentiamo i 5xx solo per i metodi idempotenti, mentre un 429 è sempre sicuro da ripetere, dato che la richiesta è stata rifiutata prima di essere elaborata.

package cfdns

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"time"
)

// maxResponseSize limita la memoria usata per leggere una singola risposta.
const maxResponseSize = 10 << 20

// doRequest esegue una chiamata all'API e decodifica il campo result nel tipo T.
func doRequest[T any](ctx context.Context, c *Client, method, path string, query url.Values, body any) (T, *ResultInfo, error) {
	var zero T

	// il corpo viene serializzato una sola volta e riletto a ogni tentativo
	var payload []byte
	if body != nil {
		var err error
		payload, err = json.Marshal(body)
		if err != nil {
			return zero, nil, fmt.Errorf("cfdns: encoding request body: %w", err)
		}
	}

	endpoint := c.baseURL + path
	if len(query) > 0 {
		endpoint += "?" + query.Encode()
	}

	for attempt := 0; ; attempt++ {
		var reader io.Reader
		if payload != nil {
			reader = bytes.NewReader(payload)
		}

		req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
		if err != nil {
			return zero, nil, err
		}
		req.Header.Set("Authorization", "Bearer "+c.token)
		req.Header.Set("Accept", "application/json")
		if payload != nil {
			req.Header.Set("Content-Type", "application/json")
		}

		resp, err := c.httpClient.Do(req)
		if err != nil {
			return zero, nil, fmt.Errorf("cfdns: %s %s: %w", method, path, err)
		}
		data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
		resp.Body.Close()
		if err != nil {
			return zero, nil, fmt.Errorf("cfdns: reading response: %w", err)
		}

		// errore temporaneo: attendiamo e ripetiamo, rispettando la cancellazione del contesto
		if shouldRetry(method, resp.StatusCode) && attempt < c.maxRetries {
			timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
			select {
			case <-ctx.Done():
				timer.Stop()
				return zero, nil, ctx.Err()
			case <-timer.C:
			}
			continue
		}

		var env envelope[T]
		if err := json.Unmarshal(data, &env); err != nil {
			return zero, nil, fmt.Errorf("cfdns: decoding response (HTTP %d): %w", resp.StatusCode, err)
		}
		if resp.StatusCode >= 400 || !env.Success {
			return zero, nil, &APIError{StatusCode: resp.StatusCode, Errors: env.Errors}
		}
		return env.Result, env.ResultInfo, nil
	}
}

// shouldRetry decide se una risposta merita un nuovo tentativo.
func shouldRetry(method string, status int) bool {
	if status == http.StatusTooManyRequests {
		return true
	}
	// una POST ripetuta dopo un 5xx potrebbe creare duplicati
	if status >= 500 {
		return method != http.MethodPost
	}
	return false
}

// retryDelay usa Retry-After se presente, altrimenti un backoff esponenziale con tetto massimo.
func retryDelay(retryAfter string, attempt int) time.Duration {
	if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
		return time.Duration(secs) * time.Second
	}
	d := 500 * time.Millisecond << attempt
	if d > 10*time.Second {
		d = 10 * time.Second
	}
	return d
}

Notate che il corpo della richiesta viene serializzato una sola volta e avvolto in un nuovo bytes.Reader a ogni tentativo: un io.Reader già consumato non può essere riletto. Il limite imposto con io.LimitReader evita invece che una risposta anomala esaurisca la memoria del processo.

Verificare il token

Prima di qualsiasi operazione è utile controllare che il token sia valido e attivo. L'endpoint di verifica restituisce l'identificativo del token e il suo stato.

package cfdns

import (
	"context"
	"net/http"
)

// TokenStatus descrive lo stato di un API Token.
type TokenStatus struct {
	ID     string `json:"id"`
	Status string `json:"status"`
}

// VerifyToken controlla che il token configurato sia valido.
func (c *Client) VerifyToken(ctx context.Context) (TokenStatus, error) {
	status, _, err := doRequest[TokenStatus](ctx, c, http.MethodGet, "/user/tokens/verify", nil, nil)
	return status, err
}

Dal nome di dominio all'ID della zona

Tutti gli endpoint DNS richiedono l'identificativo della zona, una stringa esadecimale di 32 caratteri. È possibile copiarlo dal pannello, ma è molto più comodo risolverlo a partire dal nome di dominio. Il filtro name dell'endpoint /zones effettua una corrispondenza esatta.

package cfdns

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/url"
)

// ErrZoneNotFound viene restituito quando nessuna zona corrisponde al nome richiesto.
var ErrZoneNotFound = errors.New("cfdns: zone not found")

// Zone contiene i campi della zona che ci interessano.
type Zone struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Status string `json:"status"`
}

// ZoneByName cerca una zona a partire dal nome di dominio (ad esempio example.com).
func (c *Client) ZoneByName(ctx context.Context, name string) (Zone, error) {
	q := url.Values{}
	q.Set("name", name)

	zones, _, err := doRequest[[]Zone](ctx, c, http.MethodGet, "/zones", q, nil)
	if err != nil {
		return Zone{}, err
	}
	if len(zones) == 0 {
		return Zone{}, fmt.Errorf("%w: %s", ErrZoneNotFound, name)
	}
	return zones[0], nil
}

Se il token non ha il permesso Zone → Zone → Read sulla zona richiesta, l'API non restituisce un errore ma semplicemente un elenco vuoto: è un comportamento da ricordare quando si diagnosticano problemi di permessi.

I record DNS e la paginazione

Per i record usiamo due tipi distinti. DNSRecord rappresenta ciò che l'API restituisce, compresi i campi di sola lettura come le date. RecordParams rappresenta invece ciò che inviamo: tutti i campi sono opzionali e i valori booleani e le stringhe che possono legittimamente essere "vuoti" sono puntatori, altrimenti non potremmo distinguere tra "non modificare" e "imposta a false". È un aspetto fondamentale per le richieste PATCH: con un semplice bool e omitempty, sarebbe impossibile disattivare il proxy di un record.

package cfdns

import (
	"context"
	"net/http"
	"net/url"
	"strconv"
	"time"
)

// TTLAuto indica a Cloudflare di gestire automaticamente il TTL.
const TTLAuto = 1

// DNSRecord rappresenta un record così come viene restituito dall'API.
type DNSRecord struct {
	ID         string    `json:"id"`
	Type       string    `json:"type"`
	Name       string    `json:"name"`
	Content    string    `json:"content"`
	TTL        int       `json:"ttl"`
	Proxied    bool      `json:"proxied"`
	Proxiable  bool      `json:"proxiable"`
	Priority   *uint16   `json:"priority,omitempty"`
	Comment    string    `json:"comment"`
	Tags       []string  `json:"tags"`
	CreatedOn  time.Time `json:"created_on"`
	ModifiedOn time.Time `json:"modified_on"`
}

// RecordParams descrive i campi inviati in creazione o modifica.
// I puntatori distinguono un valore assente da un valore zero.
type RecordParams struct {
	Type     string   `json:"type,omitempty"`
	Name     string   `json:"name,omitempty"`
	Content  string   `json:"content,omitempty"`
	TTL      int      `json:"ttl,omitempty"`
	Proxied  *bool    `json:"proxied,omitempty"`
	Priority *uint16  `json:"priority,omitempty"`
	Comment  *string  `json:"comment,omitempty"`
	Tags     []string `json:"tags,omitempty"`
}

// Funzioni di comodo per ottenere puntatori a valori letterali.
func Bool(v bool) *bool       { return &v }
func String(v string) *string { return &v }
func Uint16(v uint16) *uint16 { return &v }

// ListOptions contiene i filtri per l'elenco dei record.
type ListOptions struct {
	Type    string
	Name    string
	Content string
	PerPage int
}

func recordsPath(zoneID string) string {
	return "/zones/" + url.PathEscape(zoneID) + "/dns_records"
}

// ListRecords restituisce tutti i record che soddisfano i filtri, scorrendo ogni pagina.
func (c *Client) ListRecords(ctx context.Context, zoneID string, opts ListOptions) ([]DNSRecord, error) {
	perPage := opts.PerPage
	if perPage <= 0 {
		perPage = 100
	}

	var all []DNSRecord
	for page := 1; ; page++ {
		q := url.Values{}
		q.Set("page", strconv.Itoa(page))
		q.Set("per_page", strconv.Itoa(perPage))
		if opts.Type != "" {
			q.Set("type", opts.Type)
		}
		if opts.Name != "" {
			q.Set("name", opts.Name)
		}
		if opts.Content != "" {
			q.Set("content", opts.Content)
		}

		records, info, err := doRequest[[]DNSRecord](ctx, c, http.MethodGet, recordsPath(zoneID), q, nil)
		if err != nil {
			return nil, err
		}
		all = append(all, records...)

		// ci fermiamo all'ultima pagina o se la risposta non contiene dati
		if info == nil || len(records) == 0 || page >= info.TotalPages {
			break
		}
	}
	return all, nil
}

Il ciclo di paginazione si basa su total_pages ma include due condizioni di sicurezza aggiuntive: l'assenza di result_info e una pagina vuota. In questo modo non rischiamo mai un ciclo infinito se la risposta dovesse cambiare formato.

Creare, modificare ed eliminare

Le operazioni di scrittura sono semplici chiamate a doRequest. Per la modifica usiamo PATCH, che aggiorna solo i campi presenti nel corpo: grazie ai tag omitempty e ai puntatori di RecordParams, i campi non impostati non vengono inviati e restano invariati. L'eliminazione restituisce soltanto l'identificativo del record rimosso, che possiamo ignorare.

package cfdns

import (
	"context"
	"net/http"
	"net/url"
)

// CreateRecord crea un nuovo record nella zona indicata.
func (c *Client) CreateRecord(ctx context.Context, zoneID string, p RecordParams) (DNSRecord, error) {
	rec, _, err := doRequest[DNSRecord](ctx, c, http.MethodPost, recordsPath(zoneID), nil, p)
	return rec, err
}

// GetRecord legge un singolo record tramite il suo identificativo.
func (c *Client) GetRecord(ctx context.Context, zoneID, recordID string) (DNSRecord, error) {
	path := recordsPath(zoneID) + "/" + url.PathEscape(recordID)
	rec, _, err := doRequest[DNSRecord](ctx, c, http.MethodGet, path, nil, nil)
	return rec, err
}

// UpdateRecord modifica parzialmente un record: i campi non impostati restano invariati.
func (c *Client) UpdateRecord(ctx context.Context, zoneID, recordID string, p RecordParams) (DNSRecord, error) {
	path := recordsPath(zoneID) + "/" + url.PathEscape(recordID)
	rec, _, err := doRequest[DNSRecord](ctx, c, http.MethodPatch, path, nil, p)
	return rec, err
}

// DeleteRecord elimina un record.
func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error {
	path := recordsPath(zoneID) + "/" + url.PathEscape(recordID)
	_, _, err := doRequest[struct {
		ID string `json:"id"`
	}](ctx, c, http.MethodDelete, path, nil, nil)
	return err
}

Un esempio di utilizzo per creare un record MX, in cui la priorità è obbligatoria:

rec, err := client.CreateRecord(ctx, zone.ID, cfdns.RecordParams{
	Type:     "MX",
	Name:     "example.com",
	Content:  "mail.example.com",
	TTL:      3600,
	Priority: cfdns.Uint16(10),
	Comment:  cfdns.String("server di posta principale"),
})
if err != nil {
	log.Fatal(err)
}
fmt.Println("creato il record", rec.ID)

Un upsert idempotente

Nella pratica, l'operazione più utile non è né la creazione né la modifica, ma la loro combinazione: "fai in modo che questo record esista con questo valore". Un upsert idempotente può essere eseguito quante volte si vuole (da uno script di deploy, da un cron, da una pipeline CI) producendo sempre lo stesso risultato, e senza effettuare scritture inutili quando il record è già corretto.

La logica è la seguente: cerchiamo i record con lo stesso tipo e nome. Se non ne esiste nessuno lo creiamo; se ne esiste uno lo aggiorniamo solo se differisce da quanto richiesto; se ne esistono più di uno ci fermiamo, perché non è possibile stabilire quale modificare. Quest'ultimo caso non è raro: più record A con lo stesso nome sono il modo standard di implementare il DNS round-robin.

package cfdns

import (
	"context"
	"errors"
	"fmt"
)

// ErrAmbiguousRecord viene restituito quando più record corrispondono a tipo e nome.
var ErrAmbiguousRecord = errors.New("cfdns: multiple records match")

// UpsertResult descrive l'esito di un upsert.
type UpsertResult int

const (
	Unchanged UpsertResult = iota
	Created
	Updated
)

func (r UpsertResult) String() string {
	switch r {
	case Created:
		return "created"
	case Updated:
		return "updated"
	default:
		return "unchanged"
	}
}

// UpsertRecord garantisce che esista un record con tipo, nome e contenuto indicati.
// Il nome deve essere completo (ad esempio home.example.com).
func (c *Client) UpsertRecord(ctx context.Context, zoneID string, p RecordParams) (DNSRecord, UpsertResult, error) {
	if p.Type == "" || p.Name == "" || p.Content == "" {
		return DNSRecord{}, Unchanged, errors.New("cfdns: upsert requires type, name and content")
	}

	existing, err := c.ListRecords(ctx, zoneID, ListOptions{Type: p.Type, Name: p.Name})
	if err != nil {
		return DNSRecord{}, Unchanged, err
	}

	switch len(existing) {
	case 0:
		rec, err := c.CreateRecord(ctx, zoneID, p)
		return rec, Created, err
	case 1:
		current := existing[0]
		if !needsUpdate(current, p) {
			return current, Unchanged, nil
		}
		rec, err := c.UpdateRecord(ctx, zoneID, current.ID, p)
		return rec, Updated, err
	default:
		return DNSRecord{}, Unchanged, fmt.Errorf("%w: %d records for %s %s",
			ErrAmbiguousRecord, len(existing), p.Type, p.Name)
	}
}

// needsUpdate confronta solo i campi effettivamente specificati nei parametri.
func needsUpdate(current DNSRecord, p RecordParams) bool {
	if current.Content != p.Content {
		return true
	}
	if p.TTL != 0 && current.TTL != p.TTL {
		return true
	}
	if p.Proxied != nil && current.Proxied != *p.Proxied {
		return true
	}
	if p.Priority != nil && (current.Priority == nil || *current.Priority != *p.Priority) {
		return true
	}
	if p.Comment != nil && current.Comment != *p.Comment {
		return true
	}
	return false
}

Il confronto sul contenuto è volutamente letterale. Per i record A e i CNAME funziona senza sorprese, ma per gli AAAA conviene normalizzare l'indirizzo prima dell'invio (ad esempio con netip.Addr.String()), perché lo stesso indirizzo IPv6 può essere scritto in più forme. Lo stesso vale per i record TXT, per i quali Cloudflare potrebbe restituire il contenuto racchiuso tra virgolette.

Operazioni batch

Quando le modifiche sono molte, eseguirle una alla volta consuma rapidamente il limite di richieste e lascia la zona in uno stato intermedio se qualcosa va storto a metà. L'endpoint /dns_records/batch risolve entrambi i problemi: accetta quattro elenchi di operazioni (deletes, patches, puts e posts), le esegue in quest'ordine all'interno di un'unica transazione e, se una di esse fallisce, annulla l'intero lotto.

Nella struttura BatchPatch incorporiamo RecordParams: il pacchetto encoding/json promuove i campi della struttura incorporata al livello superiore, producendo esattamente il formato atteso, cioè l'identificativo affiancato ai campi da modificare.

package cfdns

import (
	"context"
	"net/http"
)

// BatchDelete identifica un record da eliminare.
type BatchDelete struct {
	ID string `json:"id"`
}

// BatchPatch unisce l'identificativo del record ai campi da modificare.
type BatchPatch struct {
	ID string `json:"id"`
	RecordParams
}

// BatchRequest raccoglie le operazioni da eseguire in un'unica transazione.
// Cloudflare le applica nell'ordine: deletes, patches, puts, posts.
type BatchRequest struct {
	Deletes []BatchDelete  `json:"deletes,omitempty"`
	Patches []BatchPatch   `json:"patches,omitempty"`
	Puts    []BatchPatch   `json:"puts,omitempty"`
	Posts   []RecordParams `json:"posts,omitempty"`
}

// BatchResult contiene i record risultanti da ciascun gruppo di operazioni.
type BatchResult struct {
	Deletes []DNSRecord `json:"deletes"`
	Patches []DNSRecord `json:"patches"`
	Puts    []DNSRecord `json:"puts"`
	Posts   []DNSRecord `json:"posts"`
}

// BatchRecords esegue più operazioni sui record in modo atomico.
func (c *Client) BatchRecords(ctx context.Context, zoneID string, req BatchRequest) (BatchResult, error) {
	res, _, err := doRequest[BatchResult](ctx, c, http.MethodPost, recordsPath(zoneID)+"/batch", nil, req)
	return res, err
}

Un caso tipico è la migrazione di un servizio da un server a un altro, in cui vogliamo che l'eliminazione dei vecchi record e la creazione dei nuovi avvengano insieme:

_, err := client.BatchRecords(ctx, zone.ID, cfdns.BatchRequest{
	Deletes: []cfdns.BatchDelete{{ID: oldRecordID}},
	Patches: []cfdns.BatchPatch{{
		ID:           apiRecordID,
		RecordParams: cfdns.RecordParams{Content: "203.0.113.20"},
	}},
	Posts: []cfdns.RecordParams{{
		Type:    "A",
		Name:    "api-v2.example.com",
		Content: "203.0.113.21",
		TTL:     cfdns.TTLAuto,
		Proxied: cfdns.Bool(true),
	}},
})
if err != nil {
	log.Fatalf("migrazione DNS annullata: %v", err)
}

Una CLI completa

Mettiamo insieme i pezzi in un comando con cinque sottocomandi: verify, list, upsert, delete e ddns. Ogni sottocomando ha il proprio flag.FlagSet, e l'intero programma usa un contesto annullato da Ctrl+C tramite signal.NotifyContext, così che anche un'attesa di retry o un ciclo DDNS terminino in modo pulito.

Il sottocomando ddns implementa un client DNS dinamico: rileva l'indirizzo IP pubblico della macchina e aggiorna il record corrispondente, una sola volta oppure a intervalli regolari. Grazie all'upsert, se l'indirizzo non è cambiato non viene effettuata alcuna scrittura.

package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/netip"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"syscall"
	"text/tabwriter"
	"time"

	"example.com/cfdns/cfdns"
)

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(2)
	}

	// il contesto viene annullato alla ricezione di SIGINT o SIGTERM
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	client, err := cfdns.NewClient(os.Getenv("CLOUDFLARE_API_TOKEN"))
	if err != nil {
		log.Fatal(err)
	}

	cmd, args := os.Args[1], os.Args[2:]
	switch cmd {
	case "verify":
		err = runVerify(ctx, client)
	case "list":
		err = runList(ctx, client, args)
	case "upsert":
		err = runUpsert(ctx, client, args)
	case "delete":
		err = runDelete(ctx, client, args)
	case "ddns":
		err = runDDNS(ctx, client, args)
	default:
		usage()
		os.Exit(2)
	}

	if err != nil && !errors.Is(err, context.Canceled) {
		log.Fatal(err)
	}
}

func usage() {
	fmt.Fprintln(os.Stderr, "usage: cfdns <verify|list|upsert|delete|ddns> [flags]")
}

func runVerify(ctx context.Context, c *cfdns.Client) error {
	status, err := c.VerifyToken(ctx)
	if err != nil {
		return err
	}
	fmt.Printf("token %s: %s\n", status.ID, status.Status)
	return nil
}

func runList(ctx context.Context, c *cfdns.Client, args []string) error {
	fs := flag.NewFlagSet("list", flag.ExitOnError)
	zoneName := fs.String("zone", "", "zone name, e.g. example.com")
	recordType := fs.String("type", "", "filter by record type")
	name := fs.String("name", "", "filter by fully qualified name")
	_ = fs.Parse(args)

	if *zoneName == "" {
		return errors.New("-zone is required")
	}
	zone, err := c.ZoneByName(ctx, *zoneName)
	if err != nil {
		return err
	}

	records, err := c.ListRecords(ctx, zone.ID, cfdns.ListOptions{
		Type: strings.ToUpper(*recordType),
		Name: *name,
	})
	if err != nil {
		return err
	}

	// output tabellare allineato
	tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
	fmt.Fprintln(tw, "ID\tTYPE\tNAME\tCONTENT\tTTL\tPROXIED")
	for _, r := range records {
		ttl := strconv.Itoa(r.TTL)
		if r.TTL == cfdns.TTLAuto {
			ttl = "auto"
		}
		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%t\n", r.ID, r.Type, r.Name, r.Content, ttl, r.Proxied)
	}
	return tw.Flush()
}

func runUpsert(ctx context.Context, c *cfdns.Client, args []string) error {
	fs := flag.NewFlagSet("upsert", flag.ExitOnError)
	zoneName := fs.String("zone", "", "zone name")
	recordType := fs.String("type", "A", "record type")
	name := fs.String("name", "", "fully qualified record name")
	content := fs.String("content", "", "record content")
	ttl := fs.Int("ttl", cfdns.TTLAuto, "TTL in seconds (1 = auto)")
	proxied := fs.Bool("proxied", false, "proxy traffic through Cloudflare")
	comment := fs.String("comment", "", "record comment")
	_ = fs.Parse(args)

	if *zoneName == "" || *name == "" || *content == "" {
		return errors.New("-zone, -name and -content are required")
	}

	params := cfdns.RecordParams{
		Type:    strings.ToUpper(*recordType),
		Name:    *name,
		Content: *content,
		TTL:     *ttl,
	}

	// inviamo solo i flag impostati esplicitamente, per non sovrascrivere valori esistenti
	fs.Visit(func(f *flag.Flag) {
		switch f.Name {
		case "proxied":
			params.Proxied = cfdns.Bool(*proxied)
		case "comment":
			params.Comment = cfdns.String(*comment)
		}
	})

	zone, err := c.ZoneByName(ctx, *zoneName)
	if err != nil {
		return err
	}
	rec, result, err := c.UpsertRecord(ctx, zone.ID, params)
	if err != nil {
		return err
	}
	fmt.Printf("%s %s %s -> %s (%s)\n", result, rec.Type, rec.Name, rec.Content, rec.ID)
	return nil
}

func runDelete(ctx context.Context, c *cfdns.Client, args []string) error {
	fs := flag.NewFlagSet("delete", flag.ExitOnError)
	zoneName := fs.String("zone", "", "zone name")
	id := fs.String("id", "", "record ID")
	recordType := fs.String("type", "", "record type (used with -name)")
	name := fs.String("name", "", "fully qualified record name (used with -type)")
	_ = fs.Parse(args)

	if *zoneName == "" {
		return errors.New("-zone is required")
	}
	zone, err := c.ZoneByName(ctx, *zoneName)
	if err != nil {
		return err
	}

	recordID := *id
	if recordID == "" {
		if *recordType == "" || *name == "" {
			return errors.New("either -id or both -type and -name are required")
		}
		records, err := c.ListRecords(ctx, zone.ID, cfdns.ListOptions{
			Type: strings.ToUpper(*recordType),
			Name: *name,
		})
		if err != nil {
			return err
		}
		// per sicurezza eliminiamo solo in presenza di una corrispondenza univoca
		if len(records) != 1 {
			return fmt.Errorf("expected exactly one matching record, found %d", len(records))
		}
		recordID = records[0].ID
	}

	if err := c.DeleteRecord(ctx, zone.ID, recordID); err != nil {
		return err
	}
	fmt.Println("deleted", recordID)
	return nil
}

func runDDNS(ctx context.Context, c *cfdns.Client, args []string) error {
	fs := flag.NewFlagSet("ddns", flag.ExitOnError)
	zoneName := fs.String("zone", "", "zone name")
	name := fs.String("name", "", "fully qualified record name")
	ipv6 := fs.Bool("ipv6", false, "update an AAAA record instead of an A record")
	proxied := fs.Bool("proxied", false, "proxy traffic through Cloudflare")
	interval := fs.Duration("interval", 0, "update interval (0 = run once)")
	_ = fs.Parse(args)

	if *zoneName == "" || *name == "" {
		return errors.New("-zone and -name are required")
	}
	zone, err := c.ZoneByName(ctx, *zoneName)
	if err != nil {
		return err
	}

	recordType := "A"
	if *ipv6 {
		recordType = "AAAA"
	}

	sync := func() error {
		ip, err := publicIP(ctx, *ipv6)
		if err != nil {
			return err
		}
		rec, result, err := c.UpsertRecord(ctx, zone.ID, cfdns.RecordParams{
			Type:    recordType,
			Name:    *name,
			Content: ip.String(),
			TTL:     cfdns.TTLAuto,
			Proxied: cfdns.Bool(*proxied),
		})
		if err != nil {
			return err
		}
		log.Printf("%s %s %s -> %s", result, rec.Type, rec.Name, rec.Content)
		return nil
	}

	// esecuzione singola, adatta a cron o a un timer systemd
	if *interval <= 0 {
		return sync()
	}

	// modalità demone: gli errori vengono registrati senza interrompere il ciclo
	ticker := time.NewTicker(*interval)
	defer ticker.Stop()
	for {
		if err := sync(); err != nil {
			log.Printf("sync failed: %v", err)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
		}
	}
}

// publicIP rileva l'indirizzo pubblico interrogando un servizio esterno.
func publicIP(ctx context.Context, v6 bool) (netip.Addr, error) {
	endpoint := "https://api.ipify.org"
	if v6 {
		endpoint = "https://api6.ipify.org"
	}

	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return netip.Addr{}, err
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return netip.Addr{}, fmt.Errorf("public IP lookup: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return netip.Addr{}, fmt.Errorf("public IP lookup: HTTP %d", resp.StatusCode)
	}
	body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
	if err != nil {
		return netip.Addr{}, err
	}

	addr, err := netip.ParseAddr(strings.TrimSpace(string(body)))
	if err != nil {
		return netip.Addr{}, fmt.Errorf("public IP lookup: %w", err)
	}

	// verifichiamo che la famiglia dell'indirizzo sia quella attesa
	addr = addr.Unmap()
	if v6 != addr.Is6() {
		return netip.Addr{}, fmt.Errorf("public IP lookup: unexpected address %s", addr)
	}
	return addr, nil
}

Alcuni esempi di utilizzo:

go build -o cfdns ./cmd/cfdns

./cfdns verify
./cfdns list -zone example.com -type A
./cfdns upsert -zone example.com -type CNAME -name blog.example.com -content example.com -proxied
./cfdns delete -zone example.com -type TXT -name _old.example.com

# aggiornamento dinamico ogni cinque minuti
./cfdns ddns -zone example.com -name home.example.com -interval 5m

Per eseguire il client DDNS come servizio, un'unità systemd minimale è sufficiente. Il token va in un file d'ambiente leggibile solo da root, non nell'unità stessa:

[Unit]
Description=Cloudflare dynamic DNS updater
After=network-online.target
Wants=network-online.target

[Service]
EnvironmentFile=/etc/cfdns/env
ExecStart=/usr/local/bin/cfdns ddns -zone example.com -name home.example.com -interval 5m
Restart=on-failure
DynamicUser=yes
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target

Testare il client senza toccare Cloudflare

Grazie all'opzione WithBaseURL, possiamo puntare il client verso un server locale creato con net/http/httptest e verificare paginazione, retry e gestione degli errori senza alcuna chiamata di rete reale e senza rischiare di modificare una zona di produzione.

package cfdns

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
)

const testToken = "test-token"

// newTestClient avvia un server di test e restituisce un client che punta a esso.
func newTestClient(t *testing.T, h http.HandlerFunc) *Client {
	t.Helper()
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)

	c, err := NewClient(testToken, WithBaseURL(srv.URL))
	if err != nil {
		t.Fatal(err)
	}
	return c
}

func TestListRecordsPagination(t *testing.T) {
	c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != "Bearer "+testToken {
			t.Errorf("missing or wrong Authorization header")
		}
		if r.URL.Path != "/zones/zone123/dns_records" {
			http.NotFound(w, r)
			return
		}
		page := r.URL.Query().Get("page")
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprintf(w, `{"success":true,"errors":[],"messages":[],
			"result":[{"id":"rec%s","type":"A","name":"www.example.com","content":"192.0.2.%s","ttl":1}],
			"result_info":{"page":%s,"per_page":1,"count":1,"total_count":2,"total_pages":2}}`,
			page, page, page)
	})

	records, err := c.ListRecords(context.Background(), "zone123", ListOptions{PerPage: 1})
	if err != nil {
		t.Fatal(err)
	}
	if len(records) != 2 {
		t.Fatalf("expected 2 records, got %d", len(records))
	}
	if records[1].Content != "192.0.2.2" {
		t.Errorf("unexpected content on second page: %s", records[1].Content)
	}
}

func TestRetryOnRateLimit(t *testing.T) {
	var calls atomic.Int32
	c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		// la prima chiamata simula il superamento del limite di frequenza
		if calls.Add(1) == 1 {
			w.WriteHeader(http.StatusTooManyRequests)
			fmt.Fprint(w, `{"success":false,"errors":[{"code":10000,"message":"rate limited"}],"messages":[],"result":null}`)
			return
		}
		fmt.Fprint(w, `{"success":true,"errors":[],"messages":[],"result":[{"id":"z1","name":"example.com","status":"active"}]}`)
	})

	zone, err := c.ZoneByName(context.Background(), "example.com")
	if err != nil {
		t.Fatal(err)
	}
	if zone.ID != "z1" {
		t.Errorf("unexpected zone ID: %s", zone.ID)
	}
	if n := calls.Load(); n != 2 {
		t.Errorf("expected 2 calls, got %d", n)
	}
}

func TestAPIError(t *testing.T) {
	c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(w, `{"success":false,"errors":[{"code":9005,"message":"Content for A record is invalid"}],"messages":[],"result":null}`)
	})

	_, err := c.CreateRecord(context.Background(), "zone123", RecordParams{
		Type: "A", Name: "bad.example.com", Content: "not-an-ip",
	})

	var apiErr *APIError
	if !errors.As(err, &apiErr) {
		t.Fatalf("expected *APIError, got %v", err)
	}
	if apiErr.StatusCode != http.StatusBadRequest || !apiErr.HasCode(9005) {
		t.Errorf("unexpected error: %v", apiErr)
	}
}

func TestNeedsUpdate(t *testing.T) {
	current := DNSRecord{Content: "192.0.2.1", TTL: 1, Proxied: true}

	if needsUpdate(current, RecordParams{Content: "192.0.2.1"}) {
		t.Error("identical content should not require an update")
	}
	if !needsUpdate(current, RecordParams{Content: "192.0.2.1", Proxied: Bool(false)}) {
		t.Error("changing proxied should require an update")
	}
	if !needsUpdate(current, RecordParams{Content: "192.0.2.2"}) {
		t.Error("changing content should require an update")
	}
}
go test ./cfdns -v -race

Considerazioni per la produzione

  • Principio del privilegio minimo: un token per ogni sistema che lo usa, limitato alle sole zone necessarie e, dove possibile, agli indirizzi IP di provenienza. Un client DDNS non ha bisogno di accedere a tutte le zone dell'account.
  • Record proxati: solo i record A, AAAA e CNAME possono passare attraverso il proxy di Cloudflare, e in quel caso il TTL è sempre automatico. Il campo proxiable restituito dall'API indica se il proxy è applicabile a un record.
  • Nomi completi: in creazione Cloudflare accetta anche il solo sottodominio, ma nelle risposte e nei filtri il nome è sempre completo. Usare sempre nomi completi nel codice evita confronti che falliscono silenziosamente.
  • Limiti di frequenza: per sincronizzazioni massive conviene preferire l'endpoint batch e, se si parallelizzano le chiamate, limitare la concorrenza con un semaforo o un pacchetto come golang.org/x/time/rate.
  • Record gestiti dal codice: i campi comment e tags sono utili per marcare i record creati dall'automazione, in modo da non toccare mai per errore quelli inseriti a mano dal pannello.
  • Registro delle modifiche: ogni scrittura andrebbe registrata con il valore precedente e quello nuovo. In caso di problemi, sapere quale processo ha cambiato un record e quando fa risparmiare molto tempo.

Conclusioni

Con poche centinaia di righe di Go e nessuna dipendenza esterna abbiamo ottenuto un client per il DNS di Cloudflare che gestisce autenticazione, paginazione, errori tipizzati, retry consapevoli dell'idempotenza, operazioni atomiche e un upsert che può essere eseguito in sicurezza da qualsiasi automazione. Lo stesso schema (una funzione generica per l'involucro delle risposte, tipi distinti per lettura e scrittura, opzioni funzionali per la configurazione) si applica senza modifiche sostanziali ad altre aree dell'API di Cloudflare, dalle regole del firewall ai Worker, ed è un buon punto di partenza per integrare la gestione DNS nei propri strumenti di deploy.