Usare le API di Cloudflare per la gestione DNS con C++
Cloudflare espone tutta la gestione delle zone DNS attraverso un'API REST versionata (v4) che restituisce JSON. Questo la rende facilmente utilizzabile da qualsiasi linguaggio in grado di effettuare richieste HTTPS, C++ compreso. In questo articolo costruiremo un piccolo client C++20 basato su libcurl e nlohmann/json in grado di verificare un token, individuare una zona, elencare, creare, aggiornare ed eliminare record DNS, gestire la paginazione e il rate limiting e, come caso d'uso concreto, aggiornare un record A con l'IP pubblico della macchina (DNS dinamico).
Come è fatta l'API DNS di Cloudflare
Tutti gli endpoint condividono l'URL base https://api.cloudflare.com/client/v4. Le operazioni che ci interessano sono poche e molto regolari:
GET /user/tokens/verify: verifica che il token sia valido e attivo.GET /zones?name=example.com: restituisce la zona e, soprattutto, il suo identificativo.GET /zones/{zone_id}/dns_records: elenca i record, con filtri cometypeenamee paginazione tramitepageeper_page.POST /zones/{zone_id}/dns_records: crea un record.PATCH /zones/{zone_id}/dns_records/{record_id}: modifica solo i campi inviati;PUTinvece sostituisce l'intero record.DELETE /zones/{zone_id}/dns_records/{record_id}: elimina un record.POST /zones/{zone_id}/dns_records/batch: applica più operazioni in un'unica transazione.
Ogni risposta usa la stessa busta: un booleano success, gli array errors e messages, il payload vero e proprio in result e, per gli elenchi, le informazioni di paginazione in result_info:
{
"success": true,
"errors": [],
"messages": [],
"result": [
{
"id": "372e67954025e0ba6aaa6d586b9e0b59",
"type": "A",
"name": "www.example.com",
"content": "203.0.113.10",
"proxied": true,
"ttl": 1,
"comment": null
}
],
"result_info": {
"page": 1,
"per_page": 100,
"count": 1,
"total_count": 1,
"total_pages": 1
}
}
Questa uniformità è il motivo per cui conviene centralizzare in un solo metodo l'invio delle richieste, il controllo di success e la trasformazione di errors in un'eccezione C++.
Creare un token API con i permessi minimi
Cloudflare supporta ancora la vecchia Global API Key, ma non va usata: dà accesso completo all'account. Dalla dashboard, nella sezione dedicata ai token API del profilo, si crea invece un token personalizzato con due soli permessi:
- Zone → Zone → Read, necessario per risolvere il nome della zona nel suo identificativo.
- Zone → DNS → Edit, necessario per leggere e modificare i record.
Nelle risorse del token conviene limitarlo alle sole zone che il programma deve gestire ed eventualmente aggiungere un filtro sugli indirizzi IP di provenienza. Il token viene mostrato una sola volta: lo salveremo in una variabile d'ambiente, CF_API_TOKEN, e non lo scriveremo mai nel codice sorgente. Prima di passare al C++ si può verificarlo da terminale:
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify \
-H "Authorization: Bearer $CF_API_TOKEN"
Si noti che l'endpoint /user/tokens/verify vale per i token creati a livello di utente. I token di proprietà di un account si verificano invece con /accounts/{account_id}/tokens/verify.
Struttura del progetto e dipendenze
Il progetto è diviso in tre unità: un wrapper RAII attorno a libcurl, il client Cloudflare vero e proprio e un piccolo programma a riga di comando.
cfdns/
├── CMakeLists.txt
└── src/
├── http_client.hpp
├── http_client.cpp
├── cloudflare_client.hpp
├── cloudflare_client.cpp
└── main.cpp
Le dipendenze sono libcurl per HTTPS e nlohmann/json per la serializzazione. Entrambe sono disponibili nei gestori di pacchetti delle principali distribuzioni e in vcpkg e Conan. Il file CMakeLists.txt è minimale:
cmake_minimum_required(VERSION 3.20)
project(cfdns LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(CURL REQUIRED)
find_package(nlohmann_json 3.11 REQUIRED)
add_executable(cfdns
src/main.cpp
src/http_client.cpp
src/cloudflare_client.cpp
)
target_compile_options(cfdns PRIVATE -Wall -Wextra -Wpedantic)
target_link_libraries(cfdns PRIVATE CURL::libcurl nlohmann_json::nlohmann_json)
Un wrapper RAII per libcurl
L'API C di libcurl richiede di gestire manualmente l'inizializzazione globale, gli handle e le liste di intestazioni. Incapsuliamo tutto in due classi: CurlGlobal, da istanziare una sola volta in main(), e HttpClient, che possiede un handle CURL* e lo riusa tra una richiesta e l'altra. Riutilizzare l'handle permette a libcurl di mantenere aperta la connessione TLS verso Cloudflare, con un risparmio evidente quando si eseguono molte chiamate di seguito, ad esempio durante la paginazione.
#pragma once
#include <curl/curl.h>
#include <map>
#include <string>
#include <string_view>
#include <vector>
// Risposta HTTP normalizzata: codice di stato, corpo e intestazioni
struct HttpResponse {
long status = 0;
std::string body;
std::map<std::string, std::string> headers; // nomi in minuscolo
};
// Inizializzazione globale di libcurl, da creare una sola volta in main()
class CurlGlobal {
public:
CurlGlobal();
~CurlGlobal();
CurlGlobal(const CurlGlobal&) = delete;
CurlGlobal& operator=(const CurlGlobal&) = delete;
};
// Wrapper RAII attorno a un handle CURL riutilizzabile
class HttpClient {
public:
HttpClient();
~HttpClient();
HttpClient(const HttpClient&) = delete;
HttpClient& operator=(const HttpClient&) = delete;
HttpClient(HttpClient&& other) noexcept;
HttpClient& operator=(HttpClient&& other) noexcept;
HttpResponse request(std::string_view method,
const std::string& url,
const std::vector<std::string>& headers = {},
const std::string& body = {});
// Codifica un valore da inserire in una query string
std::string escape(std::string_view value) const;
private:
CURL* handle_ = nullptr;
};
Nell'implementazione, curl_easy_reset() azzera le opzioni della richiesta precedente senza chiudere le connessioni. Il verbo HTTP viene impostato con CURLOPT_CUSTOMREQUEST, così lo stesso metodo gestisce GET, POST, PATCH e DELETE. Le intestazioni di risposta vengono raccolte con i nomi in minuscolo perché HTTP le considera case-insensitive: ci servirà per leggere Retry-After.
#include "http_client.hpp"
#include <algorithm>
#include <cctype>
#include <memory>
#include <stdexcept>
#include <utility>
namespace {
// Accumula il corpo della risposta nella std::string passata come userdata
size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
auto* out = static_cast<std::string*>(userdata);
out->append(ptr, size * nmemb);
return size * nmemb;
}
// Riceve una riga di intestazione alla volta, ad esempio "Retry-After: 30\r\n"
size_t headerCallback(char* buffer, size_t size, size_t nitems, void* userdata) {
auto* headers = static_cast<std::map<std::string, std::string>*>(userdata);
const std::string line(buffer, size * nitems);
const auto colon = line.find(':');
if (colon != std::string::npos) {
std::string name = line.substr(0, colon);
std::string value = line.substr(colon + 1);
std::transform(name.begin(), name.end(), name.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
// Rimuove spazi iniziali e il terminatore CRLF
const auto first = value.find_first_not_of(" \t");
const auto last = value.find_last_not_of(" \t\r\n");
value = (first == std::string::npos) ? "" : value.substr(first, last - first + 1);
(*headers)[name] = value;
}
return size * nitems;
}
struct SlistDeleter {
void operator()(curl_slist* list) const { curl_slist_free_all(list); }
};
} // namespace
CurlGlobal::CurlGlobal() {
if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) {
throw std::runtime_error("curl_global_init non riuscita");
}
}
CurlGlobal::~CurlGlobal() {
curl_global_cleanup();
}
HttpClient::HttpClient() : handle_(curl_easy_init()) {
if (handle_ == nullptr) {
throw std::runtime_error("curl_easy_init non riuscita");
}
}
HttpClient::~HttpClient() {
if (handle_ != nullptr) {
curl_easy_cleanup(handle_);
}
}
HttpClient::HttpClient(HttpClient&& other) noexcept
: handle_(std::exchange(other.handle_, nullptr)) {}
HttpClient& HttpClient::operator=(HttpClient&& other) noexcept {
if (this != &other) {
if (handle_ != nullptr) {
curl_easy_cleanup(handle_);
}
handle_ = std::exchange(other.handle_, nullptr);
}
return *this;
}
HttpResponse HttpClient::request(std::string_view method,
const std::string& url,
const std::vector<std::string>& headers,
const std::string& body) {
// Riporta l'handle allo stato iniziale mantenendo connessioni e cache DNS
curl_easy_reset(handle_);
HttpResponse response;
const std::string verb(method);
std::unique_ptr<curl_slist, SlistDeleter> headerList;
for (const auto& header : headers) {
curl_slist* appended = curl_slist_append(headerList.get(), header.c_str());
if (appended == nullptr) {
throw std::runtime_error("impossibile allocare le intestazioni HTTP");
}
headerList.release();
headerList.reset(appended);
}
curl_easy_setopt(handle_, CURLOPT_URL, url.c_str());
curl_easy_setopt(handle_, CURLOPT_CUSTOMREQUEST, verb.c_str());
curl_easy_setopt(handle_, CURLOPT_HTTPHEADER, headerList.get());
curl_easy_setopt(handle_, CURLOPT_USERAGENT, "cfdns/1.0");
curl_easy_setopt(handle_, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(handle_, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(handle_, CURLOPT_WRITEFUNCTION, writeCallback);
curl_easy_setopt(handle_, CURLOPT_WRITEDATA, &response.body);
curl_easy_setopt(handle_, CURLOPT_HEADERFUNCTION, headerCallback);
curl_easy_setopt(handle_, CURLOPT_HEADERDATA, &response.headers);
if (!body.empty()) {
curl_easy_setopt(handle_, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(handle_, CURLOPT_POSTFIELDSIZE_LARGE,
static_cast<curl_off_t>(body.size()));
}
const CURLcode code = curl_easy_perform(handle_);
if (code != CURLE_OK) {
throw std::runtime_error(std::string("errore di trasporto: ") +
curl_easy_strerror(code));
}
curl_easy_getinfo(handle_, CURLINFO_RESPONSE_CODE, &response.status);
return response;
}
std::string HttpClient::escape(std::string_view value) const {
char* encoded = curl_easy_escape(handle_, value.data(), static_cast<int>(value.size()));
if (encoded == nullptr) {
throw std::runtime_error("curl_easy_escape non riuscita");
}
std::string result(encoded);
curl_free(encoded);
return result;
}
Due dettagli meritano attenzione. La lista curl_slist è gestita da un std::unique_ptr con deleter personalizzato, quindi viene liberata anche se una delle chiamate successive lancia un'eccezione. Inoltre la verifica del certificato TLS resta attiva, come da impostazione predefinita di libcurl: disattivarla con CURLOPT_SSL_VERIFYPEER significherebbe inviare il token a chiunque sia in grado di intercettare la connessione.
Il client Cloudflare
L'interfaccia del client rispecchia gli endpoint visti prima. DnsRecord rappresenta un record in forma tipizzata, RecordFilter raccoglie i filtri opzionali dell'elenco e CloudflareError trasporta sia lo stato HTTP sia gli errori strutturati restituiti dall'API.
#pragma once
#include "http_client.hpp"
#include <nlohmann/json.hpp>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
using json = nlohmann::json;
// Singolo errore restituito dall'API nell'array "errors"
struct ApiError {
int code = 0;
std::string message;
};
// Eccezione che conserva lo stato HTTP e gli errori riportati da Cloudflare
class CloudflareError : public std::runtime_error {
public:
CloudflareError(long status, std::vector<ApiError> errors);
long status() const noexcept { return status_; }
const std::vector<ApiError>& errors() const noexcept { return errors_; }
private:
static std::string describe(long status, const std::vector<ApiError>& errors);
long status_;
std::vector<ApiError> errors_;
};
struct DnsRecord {
std::string id;
std::string type;
std::string name;
std::string content;
int ttl = 1; // 1 significa "automatico"
bool proxied = false;
std::optional<int> priority; // solo per MX, SRV e URI
std::string comment;
};
// Filtri opzionali per l'elenco dei record
struct RecordFilter {
std::optional<std::string> type;
std::optional<std::string> name;
};
class CloudflareClient {
public:
explicit CloudflareClient(std::string apiToken,
std::string baseUrl = "https://api.cloudflare.com/client/v4");
bool verifyToken();
std::string findZoneId(const std::string& zoneName);
std::vector<DnsRecord> listRecords(const std::string& zoneId,
const RecordFilter& filter = {});
DnsRecord createRecord(const std::string& zoneId, const DnsRecord& record);
DnsRecord updateRecord(const std::string& zoneId, const std::string& recordId,
const json& changes);
void deleteRecord(const std::string& zoneId, const std::string& recordId);
// Crea il record se manca, lo aggiorna se il contenuto è cambiato
DnsRecord upsertRecord(const std::string& zoneId, const DnsRecord& desired);
// Applica più modifiche in un'unica transazione
json batch(const std::string& zoneId, const json& operations);
private:
json call(std::string_view method, const std::string& path,
const std::optional<json>& payload = std::nullopt);
std::string token_;
std::string baseUrl_;
HttpClient http_;
int maxRetries_ = 3;
};
DnsRecord parseRecord(const json& j);
json toPayload(const DnsRecord& record);
Il cuore dell'implementazione è il metodo privato call(). Aggiunge l'intestazione Authorization: Bearer, serializza l'eventuale payload, ritenta in caso di errori temporanei e controlla il campo success. Tutti i metodi pubblici si riducono così a costruire il percorso corretto e a interpretare result.
#include "cloudflare_client.hpp"
#include <chrono>
#include <sstream>
#include <thread>
#include <utility>
namespace {
// Legge l'intestazione Retry-After (in secondi) oppure usa un backoff esponenziale
std::chrono::seconds retryDelay(const HttpResponse& response, int attempt) {
if (const auto it = response.headers.find("retry-after"); it != response.headers.end()) {
try {
return std::chrono::seconds(std::stoi(it->second));
} catch (const std::exception&) {
// Valore non numerico: si ripiega sul backoff
}
}
return std::chrono::seconds(1 << attempt);
}
std::vector<ApiError> extractErrors(const json& body) {
std::vector<ApiError> errors;
if (body.contains("errors") && body["errors"].is_array()) {
for (const auto& e : body["errors"]) {
errors.push_back({e.value("code", 0), e.value("message", std::string{})});
}
}
return errors;
}
} // namespace
CloudflareError::CloudflareError(long status, std::vector<ApiError> errors)
: std::runtime_error(describe(status, errors)),
status_(status),
errors_(std::move(errors)) {}
std::string CloudflareError::describe(long status, const std::vector<ApiError>& errors) {
std::ostringstream out;
out << "Cloudflare API, HTTP " << status;
for (const auto& e : errors) {
out << " [" << e.code << "] " << e.message << ";";
}
return out.str();
}
DnsRecord parseRecord(const json& j) {
DnsRecord record;
record.id = j.value("id", std::string{});
record.type = j.value("type", std::string{});
record.name = j.value("name", std::string{});
record.content = j.value("content", std::string{});
record.ttl = j.value("ttl", 1);
record.proxied = j.value("proxied", false);
if (j.contains("priority") && j["priority"].is_number_integer()) {
record.priority = j["priority"].get<int>();
}
// Il campo comment può valere null: si accetta solo se è una stringa
if (j.contains("comment") && j["comment"].is_string()) {
record.comment = j["comment"].get<std::string>();
}
return record;
}
json toPayload(const DnsRecord& record) {
json payload = {
{"type", record.type},
{"name", record.name},
{"content", record.content},
{"ttl", record.ttl},
};
// Solo A, AAAA e CNAME possono passare dal proxy di Cloudflare
if (record.type == "A" || record.type == "AAAA" || record.type == "CNAME") {
payload["proxied"] = record.proxied;
}
if (record.priority) {
payload["priority"] = *record.priority;
}
if (!record.comment.empty()) {
payload["comment"] = record.comment;
}
return payload;
}
CloudflareClient::CloudflareClient(std::string apiToken, std::string baseUrl)
: token_(std::move(apiToken)), baseUrl_(std::move(baseUrl)) {}
json CloudflareClient::call(std::string_view method, const std::string& path,
const std::optional<json>& payload) {
const std::string url = baseUrl_ + path;
const std::vector<std::string> headers = {
"Authorization: Bearer " + token_,
"Content-Type: application/json",
"Accept: application/json",
};
const std::string body = payload ? payload->dump() : std::string{};
for (int attempt = 0;; ++attempt) {
HttpResponse response = http_.request(method, url, headers, body);
// 429 (rate limit) e 5xx sono errori temporanei: si ritenta
const bool transient = response.status == 429 || response.status >= 500;
if (transient && attempt < maxRetries_) {
std::this_thread::sleep_for(retryDelay(response, attempt));
continue;
}
const json parsed = json::parse(response.body, nullptr, false);
if (parsed.is_discarded()) {
throw CloudflareError(response.status, {{0, "risposta non in formato JSON"}});
}
if (!parsed.value("success", false)) {
throw CloudflareError(response.status, extractErrors(parsed));
}
return parsed;
}
}
bool CloudflareClient::verifyToken() {
const json response = call("GET", "/user/tokens/verify");
return response["result"].value("status", std::string{}) == "active";
}
std::string CloudflareClient::findZoneId(const std::string& zoneName) {
const json response = call("GET", "/zones?name=" + http_.escape(zoneName));
const json& zones = response["result"];
if (!zones.is_array() || zones.empty()) {
throw std::runtime_error("zona non trovata: " + zoneName);
}
return zones.front().at("id").get<std::string>();
}
std::vector<DnsRecord> CloudflareClient::listRecords(const std::string& zoneId,
const RecordFilter& filter) {
std::vector<DnsRecord> records;
int page = 1;
int totalPages = 1;
do {
std::string path = "/zones/" + zoneId + "/dns_records?per_page=100&page=" +
std::to_string(page);
if (filter.type) {
path += "&type=" + http_.escape(*filter.type);
}
if (filter.name) {
path += "&name=" + http_.escape(*filter.name);
}
const json response = call("GET", path);
for (const auto& item : response["result"]) {
records.push_back(parseRecord(item));
}
// result_info descrive la paginazione della risposta corrente
if (response.contains("result_info")) {
totalPages = response["result_info"].value("total_pages", 1);
}
++page;
} while (page <= totalPages);
return records;
}
DnsRecord CloudflareClient::createRecord(const std::string& zoneId, const DnsRecord& record) {
const json response = call("POST", "/zones/" + zoneId + "/dns_records", toPayload(record));
return parseRecord(response["result"]);
}
DnsRecord CloudflareClient::updateRecord(const std::string& zoneId,
const std::string& recordId,
const json& changes) {
const json response =
call("PATCH", "/zones/" + zoneId + "/dns_records/" + recordId, changes);
return parseRecord(response["result"]);
}
void CloudflareClient::deleteRecord(const std::string& zoneId, const std::string& recordId) {
call("DELETE", "/zones/" + zoneId + "/dns_records/" + recordId);
}
DnsRecord CloudflareClient::upsertRecord(const std::string& zoneId, const DnsRecord& desired) {
const auto existing = listRecords(zoneId, {desired.type, desired.name});
if (existing.empty()) {
return createRecord(zoneId, desired);
}
const DnsRecord& current = existing.front();
const bool unchanged = current.content == desired.content &&
current.ttl == desired.ttl &&
current.proxied == desired.proxied;
if (unchanged) {
return current;
}
// PATCH invia solo i campi da modificare
json changes = {{"content", desired.content}, {"ttl", desired.ttl}};
if (desired.type == "A" || desired.type == "AAAA" || desired.type == "CNAME") {
changes["proxied"] = desired.proxied;
}
return updateRecord(zoneId, current.id, changes);
}
json CloudflareClient::batch(const std::string& zoneId, const json& operations) {
const json response =
call("POST", "/zones/" + zoneId + "/dns_records/batch", operations);
return response["result"];
}
Alcune scelte meritano un commento:
- Rate limiting. L'API impone un limite sul numero di richieste per intervallo di tempo e, quando viene superato, risponde con
429. In quel caso, e per gli errori5xx,call()attende il numero di secondi indicato inRetry-Afteroppure applica un backoff esponenziale (1, 2, 4 secondi) prima di ritentare. - Parsing difensivo.
json::parseviene chiamato con il terzo argomento afalse, così un corpo non valido (ad esempio una pagina HTML restituita da un proxy) produce un valore scartato invece di un'eccezione di parsing poco leggibile. Allo stesso modo il campocomment, che può valerenull, viene letto solo se è effettivamente una stringa. - Il campo
proxied. Solo i recordA,AAAAeCNAMEpossono passare dal proxy di Cloudflare, quinditoPayload()include il campo solo per questi tipi. - TTL. Il valore
1indica un TTL automatico. Per i record in proxy il TTL è sempre automatico, qualunque valore si invii. - Paginazione.
listRecords()leggeresult_info.total_pagese prosegue finché non ha raccolto tutte le pagine, per cui il chiamante riceve sempre l'elenco completo. - Idempotenza.
upsertRecord()cerca il record per tipo e nome, lo crea se manca, non fa nulla se è già corretto e altrimenti invia unaPATCHcon i soli campi modificati. Può quindi essere eseguito ripetutamente senza effetti collaterali.
Operazioni in blocco con l'endpoint batch
Quando occorre modificare più record insieme, ad esempio durante una migrazione, eseguire una richiesta per record ha due svantaggi: consuma il limite di richieste e lascia la zona in uno stato intermedio se una delle chiamate fallisce. L'endpoint /dns_records/batch accetta quattro array opzionali, deletes, patches, puts e posts, che vengono eseguiti in quest'ordine all'interno di un'unica transazione: se un'operazione fallisce, nessuna viene applicata.
{
"deletes": [
{ "id": "023e105f4ecef8ad9ca31a8372d0c353" }
],
"patches": [
{ "id": "372e67954025e0ba6aaa6d586b9e0b59", "content": "203.0.113.20" }
],
"posts": [
{ "type": "A", "name": "api.example.com", "content": "203.0.113.30", "ttl": 300, "proxied": true }
]
}
Con il metodo batch() del client la stessa richiesta si costruisce direttamente in C++:
// Sostituisce l'IP di un record ed elimina un record obsoleto in un'unica transazione
const json operations = {
{"deletes", json::array({{{"id", obsoleteId}}})},
{"patches", json::array({{{"id", wwwId}, {"content", "203.0.113.20"}}})},
};
const json result = client.batch(zoneId, operations);
Il programma a riga di comando
Il file main.cpp mette insieme i pezzi in un piccolo strumento con quattro comandi: list, upsert, delete e ddns. Quest'ultimo recupera l'IP pubblico tramite il servizio api.ipify.org, ne controlla il formato con un'espressione regolare e aggiorna il record A indicato solo se l'indirizzo è cambiato.
#include "cloudflare_client.hpp"
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <regex>
#include <string>
#include <vector>
namespace {
void printUsage() {
std::cerr << "Uso:\n"
<< " cfdns list <zona> [tipo]\n"
<< " cfdns upsert <zona> <tipo> <nome> <contenuto> [ttl] [--proxied]\n"
<< " cfdns delete <zona> <tipo> <nome>\n"
<< " cfdns ddns <zona> <nome>\n";
}
// Recupera l'indirizzo IPv4 pubblico della macchina
std::string fetchPublicIp(HttpClient& http) {
const HttpResponse response = http.request("GET", "https://api.ipify.org");
static const std::regex ipv4(R"(^(\d{1,3}\.){3}\d{1,3}$)");
if (response.status != 200 || !std::regex_match(response.body, ipv4)) {
throw std::runtime_error("impossibile determinare l'IP pubblico");
}
return response.body;
}
void printRecords(const std::vector<DnsRecord>& records) {
for (const auto& r : records) {
std::cout << std::left << std::setw(7) << r.type
<< std::setw(40) << r.name
<< std::setw(6) << (r.ttl == 1 ? std::string("auto") : std::to_string(r.ttl))
<< (r.proxied ? "proxied " : "dns-only ")
<< r.content << '\n';
}
}
} // namespace
int main(int argc, char* argv[]) {
const std::vector<std::string> args(argv + 1, argv + argc);
if (args.size() < 2) {
printUsage();
return 2;
}
// Il token non va mai scritto nel codice né passato come argomento
const char* token = std::getenv("CF_API_TOKEN");
if (token == nullptr || *token == '\0') {
std::cerr << "Variabile d'ambiente CF_API_TOKEN non impostata\n";
return 2;
}
try {
CurlGlobal curlGlobal;
CloudflareClient client(token);
if (!client.verifyToken()) {
std::cerr << "Il token non è attivo\n";
return 1;
}
const std::string& command = args[0];
const std::string zoneId = client.findZoneId(args[1]);
if (command == "list") {
RecordFilter filter;
if (args.size() > 2) {
filter.type = args[2];
}
printRecords(client.listRecords(zoneId, filter));
} else if (command == "upsert" && args.size() >= 5) {
DnsRecord desired;
desired.type = args[2];
desired.name = args[3];
desired.content = args[4];
for (std::size_t i = 5; i < args.size(); ++i) {
if (args[i] == "--proxied") {
desired.proxied = true;
} else {
desired.ttl = std::stoi(args[i]);
}
}
const DnsRecord result = client.upsertRecord(zoneId, desired);
printRecords({result});
} else if (command == "delete" && args.size() >= 4) {
const auto matches = client.listRecords(zoneId, {args[2], args[3]});
if (matches.empty()) {
std::cerr << "Nessun record corrispondente\n";
return 1;
}
for (const auto& r : matches) {
client.deleteRecord(zoneId, r.id);
std::cout << "Eliminato " << r.type << ' ' << r.name << '\n';
}
} else if (command == "ddns" && args.size() >= 3) {
HttpClient http;
DnsRecord desired;
desired.type = "A";
desired.name = args[2];
desired.content = fetchPublicIp(http);
desired.ttl = 300;
desired.comment = "Aggiornato da cfdns";
const DnsRecord result = client.upsertRecord(zoneId, desired);
printRecords({result});
} else {
printUsage();
return 2;
}
} catch (const CloudflareError& e) {
std::cerr << e.what() << '\n';
return 1;
} catch (const std::exception& e) {
std::cerr << "Errore: " << e.what() << '\n';
return 1;
}
return 0;
}
Compilazione e utilizzo
# Debian / Ubuntu
sudo apt install build-essential cmake libcurl4-openssl-dev nlohmann-json3-dev
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
Una volta compilato, lo strumento si usa così:
export CF_API_TOKEN="il-tuo-token"
./build/cfdns list example.com
./build/cfdns list example.com MX
./build/cfdns upsert example.com A www.example.com 203.0.113.10 300 --proxied
./build/cfdns upsert example.com TXT example.com "v=spf1 mx -all"
./build/cfdns delete example.com A old.example.com
./build/cfdns ddns example.com home.example.com
Per il DNS dinamico basta eseguire periodicamente il comando ddns. Con systemd si definisce un servizio di tipo oneshot che legge il token da un file di ambiente leggibile solo da root (chmod 600 /etc/cfdns.env):
[Unit]
Description=Aggiornamento DNS dinamico su Cloudflare
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=/etc/cfdns.env
ExecStart=/usr/local/bin/cfdns ddns example.com home.example.com
e un timer che lo avvia ogni cinque minuti:
[Unit]
Description=Esegue cfdns ogni 5 minuti
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
Grazie alla logica di upsertRecord(), le esecuzioni in cui l'IP non è cambiato si limitano a tre richieste di sola lettura (verifica del token, ricerca della zona e ricerca del record), ben lontane dai limiti dell'API. Se si vuole ridurre ulteriormente il traffico, si può salvare in cache l'identificativo della zona oppure l'ultimo IP noto.
Considerazioni sulla sicurezza
- Usare sempre token API con permessi limitati alle zone necessarie, mai la Global API Key.
- Passare il token tramite variabile d'ambiente o file con permessi ristretti, mai come argomento da riga di comando: gli argomenti sono visibili a tutti gli utenti tramite
ps. - Non registrare nei log le intestazioni delle richieste e non abilitare
CURLOPT_VERBOSEin produzione, perché stamperebbe l'intestazioneAuthorization. - Lasciare attiva la verifica dei certificati TLS.
- Impostare una data di scadenza per il token e ruotarlo periodicamente.
Conclusioni
Con circa seicento righe di C++ moderno, commenti compresi, abbiamo ottenuto un client completo per la gestione DNS su Cloudflare: RAII per le risorse di libcurl, eccezioni tipizzate per gli errori dell'API, gestione trasparente di paginazione e rate limiting e operazioni idempotenti adatte all'automazione. Da qui è semplice estendere il client ad altri endpoint della stessa API, ad esempio la gestione delle impostazioni della zona o lo svuotamento della cache, riusando lo stesso metodo call().