Usare le API di Cloudflare per la gestione DNS con Node.js

Usare le API di Cloudflare per la gestione DNS con Node.js

Gestire i record DNS dal pannello di controllo di Cloudflare va benissimo finché le zone sono poche e le modifiche rare. Quando però i domini aumentano, quando gli ambienti di staging nascono e muoiono con le pipeline CI/CD o quando un server domestico cambia indirizzo IP senza preavviso, l'interfaccia web diventa un collo di bottiglia. Le API v4 di Cloudflare permettono di automatizzare ogni operazione sui record DNS, e Node.js, grazie al supporto nativo di fetch, è uno strumento ideale per costruire client leggeri e senza dipendenze. In questo articolo realizzeremo passo dopo passo un piccolo client riutilizzabile, lo useremo per le operazioni CRUD sui record, gestiremo paginazione, errori e rate limiting, e concluderemo con due casi d'uso concreti: un aggiornamento DNS dinamico e una sincronizzazione dichiarativa della zona a partire da un file JSON.

Prerequisiti

Per seguire gli esempi servono:

  • Node.js 18 o superiore (useremo fetch nativo e i moduli ES);
  • un account Cloudflare con almeno una zona attiva;
  • un API token con i permessi adeguati.

Tutti gli esempi usano la sintassi ES module, quindi il file package.json del progetto deve contenere la proprietà "type": "module".

{
  "name": "cloudflare-dns-manager",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=18"
  },
  "scripts": {
    "ddns": "node ddns.js",
    "sync": "node sync.js"
  }
}

Creare un API token

Cloudflare supporta due metodi di autenticazione: la vecchia Global API Key, associata all'intero account, e gli API token, che possono essere limitati a specifici permessi e a specifiche zone. La Global API Key concede accesso completo a tutto l'account e non dovrebbe mai essere usata negli script: un token compromesso con permessi minimi causa danni molto più contenuti.

Dalla dashboard, nella sezione My Profile > API Tokens, si crea un nuovo token partendo dal modello Edit zone DNS. I permessi necessari sono:

  • Zone > DNS > Edit per leggere e modificare i record;
  • Zone > Zone > Read per poter risolvere il nome del dominio nel relativo identificativo di zona.

Nella sezione Zone Resources conviene includere solo le zone effettivamente gestite dallo script. Opzionalmente si può anche limitare l'uso del token a determinati indirizzi IP e impostare una data di scadenza.

Il token va conservato in una variabile d'ambiente e mai inserito nel codice sorgente:

export CF_API_TOKEN="il-tuo-token"
export CF_ZONE_NAME="example.com"

Prima di scrivere qualsiasi riga di JavaScript possiamo verificare che il token funzioni con una semplice richiesta tramite curl:

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

Se tutto è corretto, la risposta conterrà "success": true e lo stato "active" del token.

La struttura delle risposte

Tutte le API v4 di Cloudflare condividono lo stesso formato di risposta. Conoscerlo è fondamentale per scrivere un client robusto:

{
  "success": true,
  "errors": [],
  "messages": [],
  "result": [
    {
      "id": "372e67954025e0ba6aaa6d586b9e0b59",
      "name": "www.example.com",
      "type": "A",
      "content": "198.51.100.4",
      "proxied": true,
      "ttl": 1,
      "comment": "Server principale"
    }
  ],
  "result_info": {
    "page": 1,
    "per_page": 100,
    "count": 1,
    "total_count": 1,
    "total_pages": 1
  }
}

Il campo success indica l'esito dell'operazione, errors contiene un array di oggetti con code e message, result contiene il dato richiesto (un oggetto o un array) e result_info, presente negli endpoint che restituiscono liste, fornisce le informazioni di paginazione. Un dettaglio da ricordare: il valore ttl: 1 significa TTL automatico, ed è l'unico valore ammesso per i record con proxy attivo.

Un client minimale

Invece di ripetere intestazioni e gestione degli errori in ogni chiamata, incapsuliamo tutto in una classe. Iniziamo con una classe di errore dedicata, che conserva il codice di stato HTTP e gli errori restituiti da Cloudflare:

// cloudflare-error.js
export class CloudflareError extends Error {
  constructor(message, { status, errors = [] } = {}) {
    super(message);
    this.name = 'CloudflareError';
    this.status = status;
    this.errors = errors;
  }
}

Il cuore del client è il metodo request(), che costruisce l'URL, invia la richiesta, interpreta la risposta e solleva un'eccezione in caso di errore. Aggiungiamo fin da subito un meccanismo di retry per le risposte 429 Too Many Requests e per gli errori 5xx, rispettando l'intestazione Retry-After quando presente.

// cloudflare-client.js
import { CloudflareError } from './cloudflare-error.js';

const BASE_URL = 'https://api.cloudflare.com/client/v4';

// Attende il numero di millisecondi indicato
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export class CloudflareClient {
  constructor({ token, maxRetries = 3, timeoutMs = 15000 } = {}) {
    if (!token) {
      throw new Error('API token mancante');
    }
    this.token = token;
    this.maxRetries = maxRetries;
    this.timeoutMs = timeoutMs;
  }

  async request(method, path, { query, body } = {}) {
    const url = new URL(BASE_URL + path);

    // Aggiunge i parametri di query ignorando i valori nulli
    if (query) {
      for (const [key, value] of Object.entries(query)) {
        if (value !== undefined && value !== null) {
          url.searchParams.set(key, String(value));
        }
      }
    }

    for (let attempt = 0; ; attempt++) {
      const response = await fetch(url, {
        method,
        headers: {
          Authorization: `Bearer ${this.token}`,
          'Content-Type': 'application/json',
        },
        body: body ? JSON.stringify(body) : undefined,
        signal: AbortSignal.timeout(this.timeoutMs),
      });

      // Ritenta in caso di rate limiting o errori lato server
      const retryable = response.status === 429 || response.status >= 500;
      if (retryable && attempt < this.maxRetries) {
        const retryAfter = Number(response.headers.get('retry-after'));
        const delay = Number.isFinite(retryAfter) && retryAfter > 0
          ? retryAfter * 1000
          : 2 ** attempt * 1000;
        await sleep(delay);
        continue;
      }

      let payload;
      try {
        payload = await response.json();
      } catch {
        throw new CloudflareError(`Risposta non valida (HTTP ${response.status})`, {
          status: response.status,
        });
      }

      if (!response.ok || !payload.success) {
        const details = (payload.errors ?? [])
          .map((e) => `[${e.code}] ${e.message}`)
          .join('; ');
        throw new CloudflareError(details || `Richiesta fallita (HTTP ${response.status})`, {
          status: response.status,
          errors: payload.errors,
        });
      }

      return payload;
    }
  }
}

Il backoff esponenziale (1, 2, 4 secondi) entra in gioco solo se Cloudflare non specifica quanto attendere. Il timeout tramite AbortSignal.timeout() evita che una connessione bloccata tenga lo script in sospeso indefinitamente.

Risolvere l'identificativo della zona

Tutti gli endpoint DNS richiedono lo zone_id, una stringa esadecimale di 32 caratteri. È visibile nella dashboard, ma è molto più comodo ricavarlo dal nome del dominio tramite l'endpoint GET /zones filtrato per nome. Aggiungiamo il metodo alla classe:

  // Restituisce l'identificativo della zona dato il nome del dominio
  async getZoneId(zoneName) {
    const { result } = await this.request('GET', '/zones', {
      query: { name: zoneName },
    });

    if (result.length === 0) {
      throw new Error(`Zona non trovata: ${zoneName}`);
    }
    return result[0].id;
  }

Poiché lo zone_id non cambia, negli script eseguiti di frequente conviene memorizzarlo in una variabile d'ambiente ed evitare la chiamata aggiuntiva.

Leggere i record con la paginazione

L'endpoint GET /zones/{zone_id}/dns_records restituisce i record in pagine. Accetta vari filtri, tra cui type, name e content, oltre a page e per_page. Una zona con molti record richiede più chiamate, quindi è utile un generatore asincrono che nasconda la paginazione a chi lo usa:

  // Itera su tutti i record DNS della zona, pagina per pagina
  async *iterateRecords(zoneId, filters = {}) {
    let page = 1;
    let totalPages = 1;

    do {
      const { result, result_info: info } = await this.request(
        'GET',
        `/zones/${zoneId}/dns_records`,
        { query: { ...filters, page, per_page: 100 } }
      );

      for (const record of result) {
        yield record;
      }

      totalPages = info?.total_pages ?? 1;
      page++;
    } while (page <= totalPages);
  }

  // Raccoglie tutti i record in un array
  async listRecords(zoneId, filters = {}) {
    const records = [];
    for await (const record of this.iterateRecords(zoneId, filters)) {
      records.push(record);
    }
    return records;
  }

  // Cerca un singolo record per tipo e nome completo
  async findRecord(zoneId, type, name) {
    const [record] = await this.listRecords(zoneId, { type, name });
    return record ?? null;
  }

Con il generatore possiamo elaborare zone di grandi dimensioni senza caricarle interamente in memoria, mentre listRecords() resta comodo nei casi più semplici. Il filtro name richiede il nome completamente qualificato: per il sottodominio api bisogna passare api.example.com, non solo api.

Creare, aggiornare ed eliminare record

Le operazioni di scrittura seguono le convenzioni REST:

  • POST /zones/{zone_id}/dns_records crea un record;
  • PUT /zones/{zone_id}/dns_records/{record_id} sostituisce interamente un record;
  • PATCH /zones/{zone_id}/dns_records/{record_id} aggiorna solo i campi indicati;
  • DELETE /zones/{zone_id}/dns_records/{record_id} elimina un record.
  async createRecord(zoneId, record) {
    const { result } = await this.request('POST', `/zones/${zoneId}/dns_records`, {
      body: record,
    });
    return result;
  }

  async updateRecord(zoneId, recordId, fields) {
    const { result } = await this.request(
      'PATCH',
      `/zones/${zoneId}/dns_records/${recordId}`,
      { body: fields }
    );
    return result;
  }

  async replaceRecord(zoneId, recordId, record) {
    const { result } = await this.request(
      'PUT',
      `/zones/${zoneId}/dns_records/${recordId}`,
      { body: record }
    );
    return result;
  }

  async deleteRecord(zoneId, recordId) {
    const { result } = await this.request(
      'DELETE',
      `/zones/${zoneId}/dns_records/${recordId}`
    );
    return result;
  }

Il corpo di un record contiene almeno type, name e content. Gli altri campi più usati sono ttl (in secondi, oppure 1 per il valore automatico), proxied (disponibile solo per i tipi A, AAAA e CNAME), comment per annotazioni libere e priority per i record MX. Ecco un esempio d'uso che riassume le quattro operazioni:

// crud-example.js
import { CloudflareClient } from './cloudflare-client.js';

const client = new CloudflareClient({ token: process.env.CF_API_TOKEN });
const zoneId = await client.getZoneId(process.env.CF_ZONE_NAME);

// Creazione di un record A con proxy attivo
const created = await client.createRecord(zoneId, {
  type: 'A',
  name: 'staging.example.com',
  content: '203.0.113.10',
  ttl: 1,
  proxied: true,
  comment: 'Ambiente di staging',
});
console.log('Creato:', created.id);

// Creazione di un record MX con priorità
await client.createRecord(zoneId, {
  type: 'MX',
  name: 'example.com',
  content: 'mail.example.com',
  priority: 10,
  ttl: 3600,
});

// Aggiornamento parziale del solo indirizzo IP
await client.updateRecord(zoneId, created.id, { content: '203.0.113.20' });

// Elenco di tutti i record A della zona
const aRecords = await client.listRecords(zoneId, { type: 'A' });
for (const r of aRecords) {
  console.log(`${r.name.padEnd(35)} ${r.content.padEnd(16)} proxied=${r.proxied}`);
}

// Eliminazione del record di staging
await client.deleteRecord(zoneId, created.id);
console.log('Eliminato:', created.id);

La scelta tra PATCH e PUT non è indifferente: con PUT ogni campo non specificato torna al valore predefinito, per cui un aggiornamento che dimentica proxied: true disattiva silenziosamente il proxy. Per modifiche puntuali PATCH è quasi sempre la scelta più sicura.

Operazioni in blocco con l'endpoint batch

Quando le modifiche sono numerose, eseguire una richiesta per record è lento e consuma rapidamente il limite di richieste (1200 ogni cinque minuti per utente). L'endpoint POST /zones/{zone_id}/dns_records/batch accetta in un'unica chiamata quattro array: deletes, patches, puts e posts. Le operazioni vengono eseguite in quest'ordine e in modo transazionale: se una fallisce, nessuna viene applicata.

  // Esegue più operazioni in un'unica richiesta atomica
  async batch(zoneId, { deletes = [], patches = [], puts = [], posts = [] }) {
    const { result } = await this.request(
      'POST',
      `/zones/${zoneId}/dns_records/batch`,
      { body: { deletes, patches, puts, posts } }
    );
    return result;
  }

Negli array deletes, patches e puts ogni elemento deve contenere l'id del record interessato; gli elementi di posts hanno invece la stessa forma usata per la creazione. La natura atomica del batch lo rende particolarmente adatto alle sincronizzazioni, dove uno stato intermedio incoerente sarebbe peggio di nessuna modifica.

Esportare e importare la zona in formato BIND

Cloudflare permette anche di esportare l'intera zona in formato BIND, utile per backup periodici o per migrare verso un altro provider. L'endpoint GET /zones/{zone_id}/dns_records/export restituisce testo semplice anziché JSON, quindi va gestito fuori dal metodo request():

  // Esporta la zona in formato BIND come testo semplice
  async exportZone(zoneId) {
    const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records/export`, {
      headers: { Authorization: `Bearer ${this.token}` },
      signal: AbortSignal.timeout(this.timeoutMs),
    });

    if (!response.ok) {
      throw new CloudflareError(`Esportazione fallita (HTTP ${response.status})`, {
        status: response.status,
      });
    }
    return response.text();
  }

Uno script di backup diventa così questione di poche righe:

// backup.js
import { writeFile } from 'node:fs/promises';
import { CloudflareClient } from './cloudflare-client.js';

const client = new CloudflareClient({ token: process.env.CF_API_TOKEN });
const zoneName = process.env.CF_ZONE_NAME;
const zoneId = await client.getZoneId(zoneName);

const zoneFile = await client.exportZone(zoneId);
const date = new Date().toISOString().slice(0, 10);
const fileName = `${zoneName}-${date}.zone`;

await writeFile(fileName, zoneFile, 'utf8');
console.log(`Backup salvato in ${fileName}`);

L'operazione inversa usa POST /zones/{zone_id}/dns_records/import con un corpo multipart/form-data contenente il file nel campo file. In Node.js si può costruire con gli oggetti nativi FormData e Blob, senza librerie esterne:

  // Importa un file di zona in formato BIND
  async importZone(zoneId, zoneFileContent, { proxied = false } = {}) {
    const form = new FormData();
    form.append('file', new Blob([zoneFileContent], { type: 'text/plain' }), 'zone.txt');
    form.append('proxied', String(proxied));

    const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records/import`, {
      method: 'POST',
      // Content-Type viene impostato automaticamente con il boundary corretto
      headers: { Authorization: `Bearer ${this.token}` },
      body: form,
    });

    const payload = await response.json();
    if (!payload.success) {
      throw new CloudflareError('Importazione fallita', {
        status: response.status,
        errors: payload.errors,
      });
    }
    return payload.result;
  }

Caso d'uso: DNS dinamico per un server domestico

Uno degli usi più comuni delle API DNS è mantenere aggiornato un record che punta a una connessione con IP pubblico dinamico. Lo script seguente rileva l'IP pubblico corrente, lo confronta con quello registrato e aggiorna il record solo se necessario. Se il record non esiste, lo crea.

// ddns.js
import { CloudflareClient } from './cloudflare-client.js';

const {
  CF_API_TOKEN,
  CF_ZONE_NAME,
  CF_ZONE_ID,
  DDNS_HOSTNAME,
  DDNS_PROXIED = 'false',
} = process.env;

// Servizi alternativi per ottenere l'IP pubblico
const IP_SERVICES = [
  'https://api.ipify.org',
  'https://ipv4.icanhazip.com',
  'https://checkip.amazonaws.com',
];

const IPV4_PATTERN = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;

async function getPublicIp() {
  for (const service of IP_SERVICES) {
    try {
      const response = await fetch(service, { signal: AbortSignal.timeout(5000) });
      const ip = (await response.text()).trim();
      if (IPV4_PATTERN.test(ip)) {
        return ip;
      }
    } catch {
      // Prova il servizio successivo
    }
  }
  throw new Error('Impossibile determinare l\'IP pubblico');
}

async function main() {
  if (!DDNS_HOSTNAME) {
    throw new Error('Variabile DDNS_HOSTNAME non impostata');
  }

  const client = new CloudflareClient({ token: CF_API_TOKEN });
  const zoneId = CF_ZONE_ID || (await client.getZoneId(CF_ZONE_NAME));
  const currentIp = await getPublicIp();
  const record = await client.findRecord(zoneId, 'A', DDNS_HOSTNAME);

  if (!record) {
    await client.createRecord(zoneId, {
      type: 'A',
      name: DDNS_HOSTNAME,
      content: currentIp,
      ttl: 300,
      proxied: DDNS_PROXIED === 'true',
      comment: 'Gestito da ddns.js',
    });
    console.log(`Record creato: ${DDNS_HOSTNAME} -> ${currentIp}`);
    return;
  }

  if (record.content === currentIp) {
    console.log(`Nessuna modifica: ${DDNS_HOSTNAME} punta già a ${currentIp}`);
    return;
  }

  await client.updateRecord(zoneId, record.id, { content: currentIp });
  console.log(`Record aggiornato: ${record.content} -> ${currentIp}`);
}

main().catch((error) => {
  console.error(`Errore: ${error.message}`);
  process.exit(1);
});

Grazie al controllo preventivo, lo script effettua una sola chiamata di scrittura quando l'IP cambia davvero, e può quindi essere eseguito spesso senza problemi. Per pianificarlo ogni cinque minuti basta una voce di crontab:

*/5 * * * * cd /opt/cloudflare-dns-manager && /usr/bin/node ddns.js >> /var/log/ddns.log 2>&1

Le variabili d'ambiente possono essere caricate con l'opzione --env-file, disponibile da Node.js 20.6, evitando di esporle nella riga di comando:

node --env-file=.env ddns.js

Caso d'uso: sincronizzazione dichiarativa della zona

Un approccio più ambizioso consiste nel descrivere lo stato desiderato della zona in un file versionato e lasciare che uno script calcoli le differenze rispetto allo stato reale, applicandole in un unico batch. È lo stesso principio alla base di strumenti come Terraform, ridotto all'essenziale. Il file di configurazione elenca i record gestiti:

{
  "zone": "example.com",
  "records": [
    { "type": "A", "name": "example.com", "content": "198.51.100.4", "proxied": true },
    { "type": "A", "name": "www.example.com", "content": "198.51.100.4", "proxied": true },
    { "type": "CNAME", "name": "blog.example.com", "content": "example.com", "proxied": true },
    { "type": "MX", "name": "example.com", "content": "mail.example.com", "priority": 10, "ttl": 3600 },
    { "type": "TXT", "name": "example.com", "content": "\"v=spf1 mx -all\"", "ttl": 3600 }
  ]
}

Per confrontare i record serve una chiave che li identifichi. Usiamo la combinazione di tipo, nome e contenuto: in questo modo più record con lo stesso nome (come più TXT o più MX) sono gestiti correttamente. Un record con la stessa chiave ma con TTL, proxy o priorità diversi viene aggiornato; un record presente solo nella configurazione viene creato; un record presente solo su Cloudflare viene eliminato, ma solo se lo script è eseguito con l'opzione --prune.

// sync.js
import { readFile } from 'node:fs/promises';
import { CloudflareClient } from './cloudflare-client.js';

const MANAGED_TYPES = new Set(['A', 'AAAA', 'CNAME', 'MX', 'TXT']);
const args = new Set(process.argv.slice(2));
const dryRun = args.has('--dry-run');
const prune = args.has('--prune');

// Normalizza un record applicando i valori predefiniti
function normalize(record) {
  const proxiable = ['A', 'AAAA', 'CNAME'].includes(record.type);
  const proxied = proxiable ? Boolean(record.proxied) : false;
  return {
    type: record.type,
    name: record.name.toLowerCase(),
    content: record.content,
    ttl: proxied ? 1 : (record.ttl ?? 1),
    proxied,
    ...(record.type === 'MX' ? { priority: record.priority ?? 10 } : {}),
  };
}

// Chiave univoca per il confronto tra stato desiderato e stato reale
const keyOf = (r) => `${r.type}|${r.name.toLowerCase()}|${r.content}`;

// Verifica se gli attributi modificabili differiscono
function needsUpdate(desired, actual) {
  return desired.ttl !== actual.ttl
    || desired.proxied !== Boolean(actual.proxied)
    || (desired.type === 'MX' && desired.priority !== actual.priority);
}

async function main() {
  const configPath = [...args].find((a) => !a.startsWith('--')) ?? 'dns.json';
  const config = JSON.parse(await readFile(configPath, 'utf8'));

  const client = new CloudflareClient({ token: process.env.CF_API_TOKEN });
  const zoneId = await client.getZoneId(config.zone);

  const desired = new Map(config.records.map((r) => {
    const normalized = normalize(r);
    return [keyOf(normalized), normalized];
  }));

  const actual = new Map();
  for await (const record of client.iterateRecords(zoneId)) {
    if (MANAGED_TYPES.has(record.type)) {
      actual.set(keyOf(record), record);
    }
  }

  const posts = [];
  const patches = [];
  const deletes = [];

  for (const [key, record] of desired) {
    const existing = actual.get(key);
    if (!existing) {
      posts.push(record);
    } else if (needsUpdate(record, existing)) {
      patches.push({ id: existing.id, ttl: record.ttl, proxied: record.proxied, priority: record.priority });
    }
  }

  if (prune) {
    for (const [key, record] of actual) {
      if (!desired.has(key)) {
        deletes.push({ id: record.id });
      }
    }
  }

  // Riepilogo delle modifiche pianificate
  for (const r of posts) console.log(`+ ${r.type.padEnd(6)} ${r.name} ${r.content}`);
  for (const p of patches) console.log(`~ ${p.id}`);
  for (const d of deletes) {
    const r = [...actual.values()].find((x) => x.id === d.id);
    console.log(`- ${r.type.padEnd(6)} ${r.name} ${r.content}`);
  }

  const total = posts.length + patches.length + deletes.length;
  if (total === 0) {
    console.log('La zona è già sincronizzata.');
    return;
  }

  if (dryRun) {
    console.log(`Dry run: ${total} modifiche non applicate.`);
    return;
  }

  await client.batch(zoneId, { posts, patches, deletes });
  console.log(`Applicate ${total} modifiche in un'unica transazione.`);
}

main().catch((error) => {
  console.error(`Errore: ${error.message}`);
  if (error.errors?.length) {
    console.error(JSON.stringify(error.errors, null, 2));
  }
  process.exit(1);
});

Il flusso di lavoro consigliato prevede sempre un'esecuzione di prova prima di quella effettiva:

node --env-file=.env sync.js dns.json --dry-run
node --env-file=.env sync.js dns.json --prune

Limitando lo script ai tipi elencati in MANAGED_TYPES, record creati automaticamente da altri servizi Cloudflare o gestiti a mano (ad esempio record SRV o CAA) non vengono toccati nemmeno con --prune. Inserendo il file dns.json in un repository Git, ogni modifica alla zona diventa una revisione tracciabile e la sincronizzazione può essere eseguita da una pipeline al merge sul ramo principale.

La libreria ufficiale

Cloudflare mantiene anche un SDK ufficiale per TypeScript e JavaScript, pubblicato su npm come cloudflare. Offre tipizzazione completa, paginazione automatica e retry integrati:

import Cloudflare from 'cloudflare';

const cf = new Cloudflare({ apiToken: process.env.CF_API_TOKEN });

// La paginazione è gestita automaticamente dall'iteratore
for await (const record of cf.dns.records.list({ zone_id: process.env.CF_ZONE_ID })) {
  console.log(record.type, record.name, record.content);
}

Per progetti estesi che usano anche altre API di Cloudflare (Workers, R2, regole del firewall) l'SDK è la scelta naturale. Per script mirati come quelli visti in questo articolo, un client scritto a mano con fetch ha il vantaggio di non avere dipendenze, di essere trasparente nel comportamento e di poter essere adattato con precisione alle proprie esigenze.

Buone pratiche

  • Principio del privilegio minimo: un token per ogni script, limitato alle sole zone e ai soli permessi necessari, con restrizione per IP quando possibile.
  • Segreti fuori dal codice: variabili d'ambiente o un gestore di segreti, mai token nel repository; il file .env va aggiunto a .gitignore.
  • Letture prima delle scritture: confrontare lo stato attuale evita chiamate inutili e rende gli script idempotenti.
  • Preferire PATCH a PUT per gli aggiornamenti parziali, così da non azzerare campi come proxied.
  • Batch per le modifiche multiple: meno richieste, meno rischio di superare il rate limit e nessuno stato intermedio incoerente.
  • Dry run e backup: esportare la zona prima di modifiche massive e simulare sempre le sincronizzazioni distruttive.
  • Usare il campo comment per indicare quale script gestisce un record, in modo che chi lavora sulla dashboard sappia che eventuali modifiche manuali verranno sovrascritte.

Conclusioni

Le API v4 di Cloudflare sono coerenti, ben documentate e sufficientemente semplici da poter essere usate con il solo fetch nativo di Node.js. Con poco più di un centinaio di righe abbiamo costruito un client che gestisce autenticazione, errori, retry, paginazione e operazioni atomiche, e lo abbiamo impiegato per risolvere due problemi reali: il DNS dinamico e la gestione dichiarativa della zona. Da qui è facile proseguire, ad esempio integrando la sincronizzazione in una pipeline GitLab CI, aggiungendo la creazione automatica di record per ogni ambiente di anteprima o estendendo il client ad altri endpoint come le regole di cache e i certificati.