Aller au contenu
Sylvain Joffraud l4w
Retour

`_acme-challenge` oublié : le wildcard DNS ne répond plus

Un TXT _acme-challenge oublié dans la zone suffit à faire taire un wildcard DNS. Fin juillet 2026, quelques minutes après avoir émis des certificats avec certbot --dns-ovh sur srv-a, supabase.dev.example.com et api.supabase.dev.example.com ont cessé de résoudre, alors que *.dev.example.com répondait toujours pour n’importe quel autre nom. La cause : le plugin certbot-dns-ovh du paquet Debian ne supprime pas ses TXT de validation, et un enregistrement posé sous un nom fait exister ce nom et tous ses ancêtres dans la zone. Or un wildcard ne s’applique jamais à un nom qui existe (RFC 4592).

Le symptôme : le wildcard DNS répond, sauf pour deux noms

Les deux services tombent d’un coup : Supabase Studio et l’API (la passerelle Kong). Un test sur trois noms couverts par le même wildcard :

zzztest.dev.example.com        -> 203.0.113.10   (OK)
supabase.dev.example.com       -> VIDE
api.supabase.dev.example.com   -> VIDE

dig affiche status: NOERROR avec une section ANSWER vide. C’est un NODATA : le nom existe, mais n’a pas d’enregistrement du type demandé. Un NXDOMAIN aurait dit « ce nom n’existe pas ». Le NOERROR est trompeur : on croit à un problème de cache ou de propagation.

Les fausses pistes : Apache, le certificat, une panne DNS

Je venais de toucher aux vhosts Apache de Supabase et d’émettre un certificat dédié pour l’API. Trois suspects évidents, tous innocents :

Un wildcard qui répond pour un nom inventé mais pas pour deux noms précis, ce n’est ni une panne DNS, ni Apache, ni le certificat : ces deux noms existent dans la zone.

Pourquoi un TXT _acme-challenge casse le wildcard DNS ?

La RFC 4592 § 2.2.2 précise qu’une synthèse wildcard ne s’applique jamais à un nom qui existe déjà dans la zone, y compris quand ce nom n’existe que comme empty non-terminal. Un empty non-terminal (ENT) est un nœud de l’arbre DNS sans aucun enregistrement, mais avec des descendants.

Pour valider le certificat de l’API en DNS-01, certbot a créé _acme-challenge.api.supabase.dev.example.com. Ce TXT, jamais supprimé, fait exister deux ENT au passage :

_acme-challenge.api.supabase.dev.example.com   <- le TXT résiduel
        api.supabase.dev.example.com           <- ENT créé au passage
            supabase.dev.example.com           <- ENT créé au passage

Ces deux noms existent désormais : *.dev.example.com ne les synthétise plus, et les requêtes A tombent en NODATA.

En inspectant la zone, j’ai trouvé 9 résidus, dont 6 accumulés sur _acme-challenge.dev au fil des renouvellements du wildcard. Le problème préexistait donc, mais il ne cassait que dev.example.com nu, que personne n’utilise. Il est devenu visible le jour où l’API a eu son propre certificat.

Pourquoi un certificat dédié ? Parce que le wildcard DNS et le wildcard TLS n’ont pas la même portée, sur les mêmes noms (le piège TLS est détaillé dans l’article sur le certificat wildcard Let’s Encrypt en DNS-01 OVH) :

Wildcard DNS (*.dev chez OVH)Wildcard TLS (*.dev.example.com)
zzz.dev.example.comcouvertcouvert
api.supabase.dev.example.comcouvert (toute profondeur)non couvert (un seul label)

Le correctif : un hook certbot qui purge les TXT

Le hook cleanup-acme-txt.py, installé dans /etc/letsencrypt/renewal-hooks/post/, tourne après chaque certbot renew. Il liste les TXT de la zone, supprime ceux dont le sous-domaine contient _acme-challenge, puis déclenche un refresh de zone : sans refresh, une suppression via l’API OVH n’est pas publiée.

Il est écrit en Python stdlib, parce que le module ovh n’est pas installé sur le serveur (le plugin Debian passe par Lexicon). La signature OVH se calcule à la main :

raw = "+".join([self.as_, self.ck, method, url, payload, ts])
sig = "$1$" + hashlib.sha1(raw.encode()).hexdigest()
def clean_zone(api, zone):
    ids = api.call("GET", "/domain/zone/%s/record?fieldType=TXT" % zone)
    stale = []
    for rid in ids:
        rec = api.call("GET", "/domain/zone/%s/record/%s" % (zone, rid))
        if "_acme-challenge" in rec.get("subDomain", ""):
            stale.append(rec)
    # ... DELETE de chaque résidu, puis :
    # api.call("POST", "/domain/zone/%s/refresh" % zone)

Le hook réutilise /etc/letsencrypt/ovh.ini (aucun nouveau secret) et sort toujours en 0 : le ménage ne doit jamais faire échouer un renouvellement.

sudo install -m 755 -o root -g root certbot-cleanup-acme-txt.py \
    /etc/letsencrypt/renewal-hooks/post/cleanup-acme-txt.py
sudo certbot renew --dry-run

Le --dry-run a simulé 6 certificats : 5 TXT créés puis purgés, zone rafraîchie. Pour le diagnostic, trois requêtes suffisent :

dig A supabase.dev.example.com @dns110.ovh.net | grep status   # NOERROR + ANSWER vide ?
dig +short TXT _acme-challenge.api.supabase.dev.example.com @dns110.ovh.net   # un résidu ?
dig +short A zzztest.dev.example.com @dns110.ovh.net           # wildcard vivant ?

Lessons learned

Questions fréquentes

Pourquoi un sous-domaine couvert par un wildcard DNS ne résout plus alors que le wildcard répond pour d’autres noms ?
Un wildcard DNS ne s’applique jamais à un nom qui existe déjà dans la zone, même si ce nom ne porte aucun enregistrement (RFC 4592 § 2.2.2). Il suffit d’un enregistrement plus bas, par exemple un TXT _acme-challenge.api.app.example.com, pour faire exister api.app et app. Ces noms répondent alors NOERROR sans réponse au lieu de l’adresse du wildcard.
Qu’est-ce qu’un empty non-terminal en DNS ?
Un empty non-terminal est un nœud de l’arbre DNS qui n’a aucun enregistrement à lui mais qui a des descendants. Créer _acme-challenge.api.app.example.com crée implicitement les empty non-terminals api.app.example.com et app.example.com. Pour le DNS, ces noms existent : une requête renvoie NODATA et non NXDOMAIN, et le wildcard ne les couvre plus.
Le plugin certbot-dns-ovh supprime-t-il ses enregistrements TXT après la validation DNS-01 ?
La version Debian du plugin certbot-dns-ovh, basée sur Lexicon, crée les TXT _acme-challenge mais ne les supprime pas après la validation. Les résidus s’accumulent à chaque renouvellement. La parade est un hook placé dans /etc/letsencrypt/renewal-hooks/post/ qui supprime ces TXT via l’API OVH puis déclenche un refresh de la zone.
Comment distinguer NODATA de NXDOMAIN avec dig ?
Avec dig A nom.example.com, NXDOMAIN s’affiche en status: NXDOMAIN : le nom n’existe pas. NODATA s’affiche en status: NOERROR avec une section ANSWER vide : le nom existe mais n’a pas d’enregistrement du type demandé. Sur une zone avec wildcard, un NODATA pour un sous-domaine signale souvent un empty non-terminal créé par un enregistrement descendant.

Partager cet article :

Article précédent
Suppression de compte RGPD : le piège ON DELETE CASCADE
Article suivant
Enums TypeScript et PostgreSQL : un test de parité sans base