Vai al contenuto

TLS DNS-01 con Caddy e Cloudflare: il cert wildcard scaduto in silenzio

TLS DNS-01 con Caddy e Cloudflare: il cert wildcard scaduto in silenzio

TLS DNS-01 con Caddy e Cloudflare: il cert wildcard scaduto in silenzio

Martedì 7 aprile 2026, ore 8:30. Un dev del team apre, via VPN, un servizio interno che usiamo internamente come dashboard di monitoraggio dei job di queue. Il browser, invece di servirgli la dashboard, gli dice your connection is not private. Apre un thread su Mattermost interno, lo legge un collega, lo legge un altro, e in cinque minuti il messaggio rimbalza fino a me: il cert sul servizio interno è scaduto. Apro la console, faccio openssl s_client -connect <servizio-interno>:443 da un nodo della rete privata, e leggo: Validity: notAfter Apr 5 06:14:22 2026 GMT. Il cert è scaduto da due giorni. La cosa peggiore: è scaduto in silenzio. Niente alert, niente warning, niente. Vedi anche gli altri post su Production.

L’ora e mezza che segue è la storia di come ci sono arrivato e di cosa ho cambiato perché non succeda di nuovo.

Il setup originale

Per i servizi interni di Romiltec, quelli non esposti pubblicamente ma raggiungibili via VPN dai membri del team, la postura standard è TLS comunque, anche dietro la rete privata. Niente zero TLS perché tanto è VPN: il modello di minaccia include macchine compromesse all’interno della VPN, e un cert valido è una difesa cheap contro classi intere di attacchi.

Per ottenere cert pubblici (Let’s Encrypt) su nomi che non risolvono pubblicamente sull’origin, l’unico sfide ACME utilizzabile è DNS-01: prova che controlli il DNS di questo dominio mettendo un record TXT. La validazione HTTP-01 richiede che il client ACME possa essere contattato sulla porta 80 dal mondo, e su un servizio VPN-only quella porta non è esposta. DNS-01 è la scelta naturale.

Lo stack scelto, fine 2025, è il pattern classico:

  • Caddy come reverse proxy davanti ai servizi interni (containerizzati)
  • Cloudflare DNS per la zona del sottodominio interno usato (un sottodominio di servizio del dominio aziendale, con record che puntano a IP della subnet privata)
  • DNS-01 challenge via il modulo caddy-dns/cloudflare, che parla con l’API Cloudflare e mette i record TXT richiesti dal challenge ACME

La configurazione di Caddy è banale, una volta che il modulo è in piedi. Caddyfile semplificato:

*.<sottodominio-interno> {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    reverse_proxy <upstream-locale>:<porta>
}

Wildcard sul sottodominio interno, perché ci sono diversi servizi che ci girano sotto e il wildcard semplifica la gestione. Token Cloudflare con scope minimo (zone DNS edit limitato alla zona del sottodominio interno), passato a Caddy via variabile d’ambiente. Tutto ortodosso.

A fine 2025, dopo aver buildato Caddy con xcaddy build includendo il modulo Cloudflare DNS, il primo issue del cert wildcard è andato in pochi secondi. Cert valido per 90 giorni (è Let’s Encrypt). Nessuno ha più toccato niente per quattro mesi.

L’indagine: il primo reload non aiuta

Tornando al martedì 7 aprile. Il primo riflesso, una volta visto che il cert è scaduto, è banale: forzare un renewal. caddy reload sul nodo, leggo il log della reload, mi aspetto di vedere Caddy partire la procedura DNS-01 e issuere un cert nuovo.

Niente. Caddy ricarica la config senza errori espliciti, ma il cert non viene rinnovato. Faccio una curl interna alla dashboard, apro journalctl -u caddy -f e provoco una nuova request. La connessione TLS torna sempre il vecchio cert scaduto. Caddy non sta neanche provando a riemetterlo.

Vado nel log di Caddy con un livello di verbosità maggiore. E lì lo trovo:

{"level":"error","ts":...,"logger":"tls.obtain",
 "msg":"will retry","error":"...module 'dns.providers.cloudflare' not registered..."}

Modulo dns.providers.cloudflare not registered. Cioè: il binary di Caddy che sta girando sul nodo non include il modulo Cloudflare DNS. Senza quel modulo, il challenge DNS-01 non può aggiungere il record TXT necessario, ACME fallisce, il renewal non parte, e Caddy ci riprova in loop a intervalli regolari, senza mai farcela.

La domanda diventa: perché il modulo non è nel binary? L’ho compilato io a fine 2025 con xcaddy build, ho verificato che funzionasse, l’ho deployato. Cosa è successo nel frattempo?

La causa: l’upgrade del binary

Vado a leggere il changelog interno del nodo. A inizio gennaio 2026, durante un giro di patch routinarie sul sistema operativo, è passato un apt upgrade che ha aggiornato anche il package Debian di Caddy (tenuto perché un altro container, su quello stesso nodo, lo usa per ragioni più semplici e va bene il binary standard). Il package standard di Caddy del repo Debian non include moduli di terze parti come caddy-dns/cloudflare. È vanilla Caddy.

L’upgrade ha sostituito il binary custom con quello vanilla del package Debian. Il filesystem del nodo aveva due Caddy: il binary custom in /usr/local/bin/caddy e quello del package in /usr/bin/caddy. Il systemd unit puntava al primo. Ma durante una riconfigurazione fatta a freddo a gennaio (di cui non ho memoria precisa, è il rumore della manutenzione di una settimana di patch) il path nel systemd unit è stato normalizzato sul package Debian standard. Il custom build, perfettamente in piedi nel filesystem, non veniva più caricato.

Da gennaio in poi, Caddy ha continuato a fare il suo lavoro principale (reverse proxy: che funziona benissimo con il binary vanilla), ma ogni tentativo di renewal del cert wildcard è fallito perché il modulo DNS Cloudflare non era più nel binary attivo. Il cert iniziale, emesso a fine 2025 con la build custom, è rimasto valido fino alla sua scadenza naturale, 5 aprile. Da quel giorno, scaduto.

Tre mesi di tentativi di renewal falliti, ogni qualche ora, finiti tutti nello stesso log, dove nessuno ha mai guardato. Perché il servizio in front-end funzionava: vecchio cert ancora valido, reverse proxy operativo, niente warning. La firma del problema era sepolta sotto la routine.

Il fix: rebuild e systemd

La rimediazione è meccanica. Tre passi, in ordine.

Uno: rebuild del binary Caddy con il modulo Cloudflare DNS. Su un nodo di build separato, xcaddy build v2.7.x --with github.com/caddy-dns/cloudflare. Output: un binary caddy di una decina di MB. Verifica con ./caddy list-modules che dns.providers.cloudflare sia nella lista. Sì, c’è.

Due: deploy del binary. Copia su /usr/local/bin/caddy-custom (volutamente con un nome diverso da caddy per evitare collisioni con futuri apt upgrade). Aggiornamento del systemd unit per puntare a quel path esplicito. Reload del daemon, restart del servizio. journalctl -u caddy per leggere il boot.

Tre: forced renewal. Caddy, alla partenza, vede il cert scaduto e prova subito a rinnovarlo. Stavolta il modulo c’è. Il challenge DNS-01 parte, il record TXT viene messo su Cloudflare, ACME valida, il cert viene emesso. Tempo totale dal restart al cert nuovo in /var/lib/caddy/...: 90 secondi. Verifica esterna: openssl s_client mostra cert nuovo, validity 90 giorni a partire da oggi. Servizio interno torna a essere raggiungibile in HTTPS senza warning.

Il tutto, dalla diagnosi al cert nuovo, in circa un’ora e venti minuti.

L’alerting che mancava

Avere il cert rinnovato è la metà del lavoro. L’altra metà è assicurarsi che la prossima volta che qualcosa va storto, lo scopro prima che il cert scada, non dopo. La domanda, postmortem onesto, è: perché non avevo già un alert su questa categoria di problemi?

La risposta è banale: non avevo alert su scadenza cert per i servizi interni. Per i pubblici sì (il monitoring esterno fa un check TLS ogni 5 minuti su ogni dominio della fleet, e alerta a 14 giorni dalla scadenza), ma per i servizi VPN-only no. Il monitoring esterno non ci arriva, e mi ero appoggiato all’idea che Caddy farà il suo lavoro di renewal automatico. Premessa razionale, ma non controllata.

L’aggiunta architetturale, fatta lo stesso pomeriggio, sono due cose.

Prima: un exporter Prometheus che legge i cert dal filesystem locale. Su ogni nodo che fa TLS termination, gira un piccolo exporter (uso una variante del classico tls-exporter di community, o un piccolo script python custom a seconda del nodo) che enumera i file di cert attivi (Caddy li tiene in /var/lib/caddy/.local/share/caddy/certificates/..., per esempio), legge la notAfter di ognuno, ed espone come metrica cert_expiry_seconds_remaining con label domain.

Seconda: un alert Prometheus su quella metrica con due soglie:

  • warning a 21 giorni dalla scadenza (per intercettare renewal che non sono partiti)
  • critical a 7 giorni dalla scadenza (per situazioni davvero scoperte)

Il critical fa firing direttamente sul notifier interno via Discord (che ho già descritto in un post precedente sul setup di Caddy + CrowdSec, vedi gli altri pezzi su Production). Il warning va in un canale meno rumoroso, dove finisce solo se persiste per più di 24 ore (Caddy normalmente prova a rinnovare a 30 giorni dalla scadenza, quindi un cert che è ancora a 21 giorni è un segnale che il renewal non sta partendo).

In più, una meta-metrica: caddy_modules_loaded (o equivalente) esposta via l’endpoint admin di Caddy, con un alert se manca dns.providers.cloudflare nella lista dei moduli su nodi che dovrebbero averlo. Questo intercetta il root cause specifico di questo incident, non solo il sintomo cert scadrà.

Cosa è andato storto in produzione

Il postmortem onesto, perché serve raccontarlo, ha tre componenti.

Una. L’apt upgrade di gennaio è passato come maintenance routine, senza un test post-upgrade specifico sui servizi che dipendono dal binary aggiornato. La regola operativa che ho aggiornato è che ogni apt upgrade su nodi che hanno componenti custom-built (Caddy con moduli, OpenResty con moduli, Nginx con moduli, qualsiasi cosa che non sia il package vanilla) ha un post-check esplicito che verifica che il binary attivo sia ancora quello custom. Una riga di check caddy list-modules | grep <modulo-atteso>, eseguita post-upgrade dal sistema di automation, e se manca alza un alert.

Due. Il pattern di nomenclatura dei binary era ambiguo. Avere un caddy in /usr/local/bin e un altro caddy in /usr/bin è stata una bomba a orologeria. Il package manager di Debian ha tutto il diritto di toccare il /usr/bin/caddy, ed è normale che lo faccia. La nuova regola interna è: i binary custom-built non si chiamano come quelli del package. Si chiamano caddy-custom, nginx-rtmp-custom, eccetera. Niente collisioni nominali.

Tre. Il monitoring di scadenza cert non copriva i servizi non esposti. Era una lacuna di disegno: avevo coperto il pubblico, dove un cert scaduto è immediatamente visibile e fatale, e avevo lasciato scoperto il privato, dove un cert scaduto è meno visibile ma non meno problematico. La regola che ho aggiornato è banale: tutti i cert TLS della fleet, pubblici o privati, hanno copertura di alerting con la stessa soglia warning a 21 giorni e critical a 7 giorni. Niente categorie protette che non meritano monitoring.

Numeri

Il bilancio dell’incident, in chiaro.

  • 1 ora e 30 minuti di disagio sui servizi interni, dalle 8:30 alle 10:00 del martedì 7 aprile
  • 0 impact pubblico (i servizi coinvolti erano VPN-only)
  • 0 lavoro davvero bloccato del team (quando il problema è emerso, parte dei membri non aveva ancora aperto le dashboard interne della giornata)
  • 90 secondi dal restart del binary corretto al cert nuovo emesso
  • 3 mesi di renewal falliti in silenzio, da gennaio a aprile
  • 1 modulo Caddy mancante (causa root)
  • 2 nuove soglie di alerting (21 giorni warning, 7 giorni critical) su tutti i cert della fleet, pubblici e privati
  • 1 nuova convenzione di nomenclatura per binary custom-built

L’esposizione reale dell’incident è stata bassa: un servizio interno raggiunto da una manciata di membri del team, in un orario in cui solo una parte era operativa. Il valore del postmortem, però, è alto: il pattern cert scaduto in silenzio per modulo mancante è applicabile ovunque ci sia un binary custom-built con un autorenew automatico, e in Romiltec di pattern così ne abbiamo qualche decina sparsi sui nodi della fleet.

Cosa rifarei diversamente

Tre cose, in ordine di importanza operativa.

Una: avrei dovuto monitorare l’autorenew dal primo giorno. La regola che ho riscritto, e che vale ben oltre questo specifico caso, è: se installi un automatismo, il primo evento di test arriva entro sei mesi, non quando scade per la prima volta. Cert TLS, backup auto-rotate, key rotation, tutto. La prima volta che il sistema deve davvero girare in autonomia non è la prima scadenza reale: è una simulazione che fai apposta entro la prima finestra di test. Per i cert, questo significa: dopo il setup, forzare manualmente un renewal entro 30-60 giorni, e validare che il flusso completo (challenge DNS, response, cert nuovo, reload del proxy) sia funzionante. Se non l’hai fatto, non sai se l’autorenew funziona davvero.

Due: avrei dovuto trattare il binary custom-built come una dipendenza pinned, non come un’assunzione. Ogni nodo che ha componenti custom-built dovrebbe avere, nella sua documentazione locale (e nel runbook della sua categoria di nodi), una nota esplicita: attenzione, qui gira Caddy custom con il modulo X, non toccare il binary senza re-build. Lo applicherò a tutti i nodi della fleet che hanno qualcosa di custom-built, in un pomeriggio di documentazione condivisa.

Tre: il monitoring di copertura parità tra pubblico e privato. La differenza tra servizio pubblico e servizio privato non è una differenza di importanza del monitoring, è una differenza di superficie e di vettore di attacco. La metrica copertura monitoring per categoria di servizio va misurata e pareggiata, non lasciata implicita.

Cosa porto a casa

Il primo cert non rinnovato è un’eccezione. Il secondo, sullo stesso pattern, è una negligenza. Il pattern cert valido a tempo zero, autorenew silenziosamente rotto è classico, ed è esattamente il tipo di failure mode dove il monitoring conta più della robustezza del setup. Un cert robusto che nessuno controlla è meno affidabile di un cert fragile che qualcuno guarda ogni giorno.

L’altro lascito, più trasversale, è il postmortem onesto sul binary custom. La gestione dei binary custom-built in produzione è una di quelle cose che funzionano per inerzia per mesi, e poi ti presentano il conto in un mattino di aprile. La gestione corretta non è esotica: è naming non ambiguo, post-check di upgrade, runbook locali, alert sui moduli caricati. Cose che mi sono scritto dopo il problema e che, retrospettivamente, sarebbero costate due ore al setup iniziale invece che un’ora e mezza di disagio cinque mesi dopo.

Quel martedì alle 10:00 il cert era nuovo, l’alerting era in piedi, il convenzione di naming era documentata. Il dev che alle 8:30 aveva aperto la dashboard non lo ha più visto cadere. Il sysadmin (cioè io) ha portato a casa una lezione che vale per tutti gli automatismi: funziona finché non lo verifichi è la peggiore garanzia possibile.