Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

HAProxy et certificats ACME : un cycle de renouvellement éprouvé

Par mis à jour le

Améliorer cette page

Pour renouveler un certificat sous HAProxy sans coupure, on obtient chaque certificat auprès d'un client ACME externe, on assemble clé et chaîne (ce que la leçon nomme le matériel) dans un PEM, puis on le charge par le socket d'administration : set ssl cert dépose le matériel, commit ssl cert le contrôle avant de le mettre en service. Cette leçon s'adresse à qui sait déjà écrire un frontend et un backend HAProxy. Elle enseigne le cycle complet autour de lego, un client ACME indépendant de HAProxy : le chemin du défi, un certificat par nom servi par SNI, le script de renouvellement et la façon d'en prouver l'effet.

La leçon suit le cycle dans l'ordre où il se construit, du port 80 jusqu'à la reprise après redémarrage.

  • Router le défi HTTP-01 jusqu'au serveur de défi du client ACME.
  • Obtenir un certificat par nom et assembler le PEM que HAProxy attend.
  • Servir deux noms par SNI et mesurer la sélection par un contre-test.
  • Charger un certificat à chaud et constater qu'un matériel cassé est refusé.
  • Écrire un script de renouvellement qui ne laisse fuiter aucune clé.

Cette leçon suppose que vous maîtrisez la configuration de base de HAProxy et que vous savez ce qu'est un défi ACME. Les faits ont été établis sur HAProxy 3.4.7 et lego 5.5.2, les versions du lab.

Cette leçon couvre-t-elle le client ACME intégré de HAProxy ?

Section intitulée « Cette leçon couvre-t-elle le client ACME intégré de HAProxy ? »

Non : elle traite un client ACME externe, pas la section acme de HAProxy. La leçon Publier en HTTPS avec HAProxy 3.4 décrit le client intégré, qui obtient et renouvelle lui-même les certificats mais reste expérimental : il exige expose-experimental-directives. Elle présente aussi crt-store, le choix du certificat par SNI et le reload. Cette page ne les redit pas.

Son périmètre est différent. Le programme qui parle à l'autorité est lego, hors de HAProxy et indépendant de sa version ; HAProxy ne fait que servir les fichiers qu'on lui remet. Trois sujets sont propres à cette page : le cycle d'un certificat (défi, émission, assemblage, chargement, reprise), la sélection par SNI avec un certificat par nom et une crt-list, et le chargement vérifié par le socket. Le principe d'un client externe est celui de Nginx TLS ; seul le chargement est propre à HAProxy.

Comment le défi HTTP-01 atteint-il le client ACME ?

Section intitulée « Comment le défi HTTP-01 atteint-il le client ACME ? »

Le défi HTTP-01 se joue en clair sur le port 80, avant que le certificat existe : une règle doit envoyer /.well-known/acme-challenge/ vers le serveur de défi du client, sans passer par la redirection vers HTTPS. Le client lego ouvre son serveur de défi sur 127.0.0.1:5002. L'autorité interroge le port 80 ; sans route, la requête atteint l'application, qui ne connaît pas le jeton, et l'émission échoue. Dans le lab, le port est déjà publié ; il manque la route, une ACL et un backend dédié.

frontend public
bind :80
acl defi path_beg /.well-known/acme-challenge/
use_backend acme if defi
default_backend applicatif
backend acme
server defi 127.0.0.1:5002

Le piège apparaît avec la redirection. HAProxy joue toutes les règles http-request avant de regarder un seul use_backend : un http-request redirect scheme https s'applique donc au défi, même si la ligne use_backend est écrite au-dessus. La redirection porte la condition unless defi. Le symptôme est trompeur : le client rapporte un 308 ou une erreur TLS sur un port 443 qui n'existe pas encore, et l'on accuse l'autorité.

On construit en trois temps : le frontend HTTP avec la route du défi, puis les certificats, puis seulement le frontend HTTPS. HAProxy refuse de démarrer sur une crt-list (la liste des certificats à servir) dont les fichiers n'existent pas, et le service tombe alors entièrement, port 80 compris. Or c'est le port 80 qui sert le défi : écrire d'emblée la configuration finale crée un verrouillage parfait, pas de certificat sans défi, pas de défi sans démarrage.

Entre chaque temps, validez avec haproxy -c -f /etc/haproxy/haproxy.cfg, puis appliquez par un reload. Après le premier temps, un curl sur le chemin du défi doit atteindre le serveur de défi du client, pas l'application.

Pourquoi un certificat par nom plutôt qu'un seul avec deux SAN ?

Section intitulée « Pourquoi un certificat par nom plutôt qu'un seul avec deux SAN ? »

Un certificat unique portant les deux noms en SAN passerait la validation stricte des deux côtés et rendrait la sélection par SNI impossible à mesurer : HAProxy présenterait le même fichier quel que soit le nom demandé. Le SAN (Subject Alternative Name) est la liste des noms qu'un certificat couvre. Le SNI (Server Name Indication) est le nom que le client annonce au début de la connexion, et que HAProxy compare à cette liste pour choisir le certificat à présenter.

La recette demande donc un certificat par nom, alpha.pub.lab.test et beta.pub.lab.test, soit un appel à lego par nom : un seul appel avec deux options -d produirait un certificat unique à deux noms, ce que la recette refuse. Les appels sont séquentiels, car le serveur de défi n'écoute que sur un port. Le frontend HTTPS les référence par une crt-list, un fichier qui énumère un certificat par ligne.

frontend securise
bind :443 ssl crt-list /etc/haproxy/certs/liste.crt
default_backend applicatif
/etc/haproxy/certs/alpha.pub.lab.test.pem
/etc/haproxy/certs/beta.pub.lab.test.pem

La liste dit quels fichiers sont servis : un fichier oublié dans un dossier ne se met pas en service tout seul, ce qui arrive avec crt pointé sur un répertoire. Le contre-test du lab présente ensuite le certificat d'un nom et le fait valider contre l'autre : cela doit échouer.

Comment HAProxy veut-il son matériel, et qui peut le lire ?

Section intitulée « Comment HAProxy veut-il son matériel, et qui peut le lire ? »

HAProxy attend un seul fichier PEM par certificat, qui concatène la clé privée et la chaîne ; lego écrit des fichiers séparés, donc l'assemblage vous revient, et c'est là que se décident les droits. Le client dépose <nom>.key et <nom>.crt sous acme/certificates/. Le .crt contient déjà la chaîne : l'autorité émet par un intermédiaire et lego le joint par défaut, donc ajouter un second fichier d'émetteur dupliquerait un maillon.

Fenêtre de terminal
umask 077
cat acme/certificates/alpha.pub.lab.test.key \
acme/certificates/alpha.pub.lab.test.crt \
> /etc/haproxy/certs/alpha.pub.lab.test.pem

Un PEM réduit à la feuille fait échouer la vérification sur « unable to get local issuer certificate », message qui accuse la racine alors que la chaîne manque ; un navigateur masque souvent le défaut en complétant la chaîne lui-même, ce que openssl ne fait pas. Côté droits, la validation exige que tout fichier portant une clé privée soit illisible par les autres. La clé du compte ACME, rangée par lego sous son dossier de travail, vaut autant : qui la détient demande des certificats au nom du compte. Fermez ce dossier à root, et posez les PEM en 0640 root:root ; HAProxy les lit en root avant d'abandonner ses privilèges.

Comment charger un certificat sans redémarrer, avec un contrôle avant service ?

Section intitulée « Comment charger un certificat sans redémarrer, avec un contrôle avant service ? »

Le socket d'administration charge un certificat en deux temps, set ssl cert puis commit ssl cert : HAProxy valide le matériel au commit, et s'il est cassé il refuse et garde le certificat précédent. C'est la différence entre un chargement vérifié et un simple chargement. Un systemctl restart charge aussi le nouveau matériel, mais il coupe les connexions en cours et laisse le service à terre si le matériel est mauvais.

Le schéma suivant montre un tour complet pour un nom. Suivez-le de haut en bas : le script lance lego, l'autorité vient chercher le jeton sur le port 80, que HAProxy route vers le serveur de défi, puis le script assemble le PEM et le charge en deux commandes.

Séquence d'un renouvellement. Le script renouveler lance lego pour un nom. Lego demande un certificat à l'autorité, qui répond par un défi HTTP-01. L'autorité interroge le port 80 de HAProxy, qui route la requête vers le serveur de défi de lego sur le port 5002. L'autorité émet le certificat et lego l'écrit sur le disque. Le script assemble clé et chaîne dans un PEM, le dépose par set ssl cert, puis le met en service par commit ssl cert : le matériel est accepté et la nouvelle série est servie par le même processus.HAProxyAutorité ACMEClient ACMElegoScriptrenouvelerécrit clé etcertificat surle disqueassemble cléet chaînedans un seulPEMmatérielaccepté,nouvelle sérieservie, mêmeprocessusrun pour un nom,ordre neufdemande un certificatpour ce nomdéfi HTTP-01 : unjeton à publierGET/.well-known/acme-cha-llenge/jeton sur leport 80route vers le serveur de défi, port 5002jeton servicertificat émis,nouveau numéro desérieset ssl cert, dépôt dans une transactioncommit ssl cert, contrôle puis mise en service
Le renouvellement s'achève par deux commandes sur le socket : le processus HAProxy ne change pas. Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

Le dépôt passe par l'entrée standard de socat, jamais par un argument : une ligne de commande est visible dans ps par tout le monde, le temps qu'elle dure. La syntaxe << du socket prend un bloc terminé par une ligne vide.

Fenêtre de terminal
{
printf 'set ssl cert %s <<\n' "$pem"
cat "$temporaire"
printf '\n\n'
} | socat -t5 stdio /run/haproxy/admin.sock
echo "commit ssl cert $pem" | socat -t5 stdio /run/haproxy/admin.sock

Il reste trois gestes. Lisez la réponse du socket et cherchez-y error ou unable to, comme le fait le corrigé du lab. Envoyez abort ssl cert avec le chemin pour annuler une transaction refusée. Enfin, réécrivez le fichier du disque avec le matériel accepté ; sans cela, le certificat en mémoire et celui du disque divergent, et un redémarrage fait un retour en arrière silencieux.

Pourquoi l'autorité refuse-t-elle parfois le renouvellement ?

Section intitulée « Pourquoi l'autorité refuse-t-elle parfois le renouvellement ? »

Parce que lego cite le certificat précédent dans l'ordre de renouvellement (c'est ARI, RFC 9773), et que l'autorité refuse si le compte demandeur n'est pas celui qui a obtenu ce certificat. Ce lab a échoué sur cette erreur lors d'un rejeu le 26 octobre 2026 : 403 unauthorized :: requester account did not request the certificate being replaced by this order. Le message accuse le certificat alors que la cause est le compte.

ARI (ACME Renewal Information) laisse le client demander à l'autorité quand renouveler, et joint à l'ordre un champ replaces qui désigne le certificat remplacé. Un compte différent suffit alors à faire refuser l'ordre, et il apparaît dès que le dossier de comptes du client est recréé. Dans le lab, la cause est à l'autre bout : l'autorité de test garde ses comptes en mémoire, si bien qu'après son redémarrage le compte conservé localement ne correspond plus à rien chez elle. Les deux moitiés du couple doivent survivre ensemble.

Le corrigé écarte le refus par --ari-disable : un ordre neuf ne remplace rien, il n'a pas à citer ce qu'il remplace. Le script garde aussi e-mail et annuaire (l'URL dir qui liste les points d'accès de l'autorité) identiques à chaque appel. Deux autres drapeaux viennent de mesures du lab avec lego v5.5.2 :

  • --renew-force : en lego 5, run ne demande pas un certificat neuf quand il en trouve un, il se comporte comme renew. Le client l'a dit : « Skip renewal: The certificate expires at 15:30:11Z, the renewal can be performed in 1m15s ». Il saute alors le renouvellement en rendant un succès.
  • --no-random-sleep : lego étale les renouvellements par un délai aléatoire, qui dépasse la vie d'un certificat de cinq minutes.

Ces choix valent pour une autorité de test. En production, on ne force pas et on garde le délai : renew ne dérange l'autorité que lorsque l'échéance approche, et forcer à chaque passage épuiserait ses limites de débit. Autre particularité de lego 5 : la commande vient en premier, ses options ensuite. --server placé avant run fait sortir le client en « flag provided but not defined » sans qu'il contacte l'autorité.

Comment écrire le script de renouvellement sans fuite de clé ?

Section intitulée « Comment écrire le script de renouvellement sans fuite de clé ? »

Le script renouveler refait le cycle pour chaque nom et n'imprime jamais ce qu'il écrit : pas de set -x, pas de PEM en argument, pas de clé dans la sortie. La validation l'exécute, puis lit les journaux. Une clé dans un journal est une clé publiée : le journal est collecté, archivé, recopié dans un rapport d'incident, et la rotation ne la rattrape pas.

#!/usr/bin/env bash
set -euo pipefail
umask 077
trap 'echo "echec a la ligne $LINENO : $BASH_COMMAND" >&2' ERR
TRAVAIL=/opt/pub-haproxy-tls-stable
for nom in alpha.pub.lab.test beta.pub.lab.test; do
"$TRAVAIL/lego" run --server https://pebble:14000/dir \
--email ops@pub.lab.test --accept-tos \
--renew-force --no-random-sleep --ari-disable \
--http --http.address :5002 -d "$nom" \
> "$TRAVAIL/dernier-ordre-$nom.log" 2>&1
# Suite : assembler le PEM, set ssl cert, commit ssl cert, install.
done

Le trap ... ERR nomme la ligne en échec : sous set -e, la première commande qui échoue termine le script avec un code 1 et aucun message. La sortie de lego va dans un fichier, dont le script doit afficher la fin avant de sortir en cas d'échec. Pour constater que l'ordre a produit du matériel neuf, comparez le numéro de série du .crt avant et après. Enfin, deux racines existent : le client approuve pebble.minica.pem pour parler à l'API, et pebble-root.pem sert à vérifier les certificats émis. Les confondre se lit comme une panne réseau alors que c'est un refus de confiance.

On le prouve par ce que le service présente, pas par ce que le disque contient : une poignée de main openssl s_client avec le nom en SNI, lue avant et après le script. Le fichier du disque et le certificat servi peuvent différer d'un seul chargement oublié ; seul le second compte pour un visiteur.

Fenêtre de terminal
echo | openssl s_client -connect 127.0.0.1:443 \
-servername alpha.pub.lab.test 2>/dev/null \
| openssl x509 -noout -serial -enddate

La validation du lab porte sur quatre contrôles, et sur eux seuls.

  • Identité. Chaque nom valide strictement, chaîne et nom d'hôte, avec la racine de l'autorité et sans désactiver la vérification, puis atteint l'application. Le contre-test doit échouer. Contrôle critique.
  • Cycle. La validation exécute renouveler et exige que le numéro de série servi change pour les deux noms. Elle pousse ensuite un certificat volontairement invalide par le socket et exige qu'il soit refusé, le précédent restant servi et valide.
  • Secrets. Tout fichier portant une clé privée sous le dossier de travail ou /etc/haproxy/certs est illisible pour les autres, et aucune clé n'apparaît dans /var/log ni dans le journal système. Contrôle critique.
  • Reprise. Elle recharge HAProxy, rejoue l'identité, redémarre la machine, puis rejoue l'identité et le renouvellement. Contrôle critique, dernier de la série : un cycle qui dépendait d'un dossier sous /run ou d'une variable de votre session se voit là, et nulle part avant.

Le lab part d'une application publiée en HTTP seulement : ni certificat, ni bind ssl, ni route pour le défi. Une autorité de test aux certificats de cinq minutes, le client lego enveloppé dans un exécutable et sa racine sont fournis, et /opt/pub-haproxy-tls-stable/recette.txt décrit noms, chemins et socket. Vous servez les deux noms en HTTPS, chacun avec son certificat, et vous livrez le script renouveler. La validation l'exécute, compare les numéros de série, pousse un matériel invalide, cherche les clés dans les journaux, puis recharge et redémarre avant de tout rejouer.

Un cycle de certificats éprouvé se construit dans l'ordre et se prouve sur le service.

  • Le client ACME est externe ; l'ACME intégré, expérimental, a sa leçon.
  • La route du défi passe avant toute redirection, avec unless defi.
  • On construit HTTP, certificats, puis HTTPS : une crt-list aux fichiers absents empêche HAProxy de démarrer.
  • Un certificat par nom rend le SNI mesurable ; le contre-test doit échouer.
  • Le chargement est vérifié : set ssl cert, commit ssl cert, et un refus garde le certificat en service.
  • Un refus « requester account » vient d'ARI et d'un compte différent.
  • Aucune clé dans un journal : pas de set -x, pas de PEM en argument.

Ces questions vérifient que vous savez dire qui présente quel certificat selon le nom demandé, et ce qu'un chargement vérifié protège. Un cycle de renouvellement se juge sur ce qu'il fait quand il échoue, pas seulement quand il réussit.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

8 questions
8 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn