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 :
- Apache : sans adresse IP, le client n’atteint jamais Apache. Ce n’est pas une erreur HTTP, c’est une résolution vide.
- Le certificat : un problème TLS donne un name mismatch dans le navigateur, pas un nom qui ne résout plus.
- Une panne DNS chez OVH : le wildcard répond pour un nom inventé, la zone est donc servie.
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.com | couvert | couvert |
api.supabase.dev.example.com | couvert (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
- Un enregistrement sous un nom désactive le wildcard DNS pour ce nom et tous ses ancêtres. Même un TXT temporaire trois niveaux plus bas crée des empty non-terminals que
*ne couvre plus. - NOERROR avec une réponse vide n’est pas un succès. C’est un NODATA : le nom existe dans la zone, ce qui est justement le symptôme d’un ENT.
- Le plugin Debian
certbot-dns-ovhne nettoie pas ses TXT_acme-challenge. Un hook post-renouvellement qui purge la zone et la rafraîchit compense ce défaut. - Wildcard DNS et wildcard TLS n’ont pas la même portée. Le DNS d’OVH couvre toutes les profondeurs, le certificat un seul label : deux limites différentes sur les mêmes noms.
- Automatiser un renouvellement sans vérifier le nettoyage programme une panne récurrente. Ici, une coupure de Supabase à chaque cycle de renouvellement, environ tous les 60 jours.