Une seule IP publique, deux serveurs : le routage SNI en mode TCP avec OpenWrt et HAProxy
J’héberge mes services sur deux machines Proxmox distinctes, volontairement non clusterisées : si l’une tombe, l’autre continue de tourner. Le jour où j’ai rapatrié le second serveur au même endroit que le premier, j’ai buté sur un problème que beaucoup d’autohébergeurs connaissent bien : je n’ai qu’une seule IP publique, et une box qui ne sait rediriger le port 443 que vers une machine.
Comment servir en HTTPS des services répartis sur deux serveurs, chacun avec son propre HAProxy et ses propres certificats, derrière un seul port 443 exposé ? La réponse tient en trois lettres : SNI. Voici l’architecture que j’ai mise en place, et surtout les pièges qui m’ont coûté des heures de debug.
Le problème, posé proprement
Mon infrastructure, dans les grandes lignes :
- une box FAI en gateway, qui fait le NAT et détient l’unique IP publique ;
- un routeur OpenWrt (un Netgear R7800) en
192.168.1.20, qui fait le vrai travail réseau ; - deux serveurs Proxmox :
srvAen192.168.1.201,srvBen192.168.1.200; - sur chaque serveur, un HAProxy qui termine le TLS et distribue vers les services ;
- AdGuard Home en
192.168.1.253pour le DNS ; - un domaine géré chez OVH, avec des certificats wildcard.
La box redirige le port 443 vers OpenWrt. Jusque-là, tout va bien. Mais OpenWrt doit maintenant décider, pour chaque connexion entrante, si elle part vers srvA ou vers srvB — sans casser le TLS de bout en bout, et sans que le routeur n’ait jamais à détenir la moindre clé privée.
Pourquoi le mode TCP, et pas un reverse proxy classique
La tentation, c’est de terminer le TLS sur le routeur et de refaire du HTTP en interne. Je n’en voulais pas, pour deux raisons.
D’abord, ça ferait d’OpenWrt un point de terminaison TLS unique : il faudrait y déployer tous les certificats, et un R7800 n’est pas taillé pour ça. Ensuite et surtout, chaque serveur gère déjà ses propres certs et son propre HAProxy. Je voulais que le routeur reste bête : qu’il se contente de lire l’aiguillage et de faire suivre le flux, sans jamais déchiffrer quoi que ce soit.
C’est exactement ce que permet HAProxy en mode tcp. Le nom de domaine visé par le client est transmis en clair dans le premier paquet de la poignée de main TLS — le champ SNI (Server Name Indication) du ClientHello. HAProxy peut le lire sans déchiffrer la session : le ClientHello précède l’établissement du chiffrement. Il route donc le flux TCP brut vers le bon backend, et c’est le serveur de destination qui termine le TLS avec son propre certificat.
Le routeur ne voit jamais les clés privées. Élégant, et robuste.
L’architecture complète
Le chemin d’une requête, de bout en bout :
Internet
→ Box FAI (NAT : 443 → 192.168.1.20)
→ OpenWrt / HAProxy [mode tcp, lit le SNI, aiguille]
→ srvA (192.168.1.201) / HAProxy [accept-proxy, termine le TLS]
→ srvB (192.168.1.200) / HAProxy [accept-proxy, termine le TLS]
→ les services
Le point délicat : comme OpenWrt ne termine pas le TLS et se contente de relayer du TCP, le serveur de destination verrait comme IP source… celle du routeur. On perdrait l’IP réelle du client. La solution est le PROXY protocol : OpenWrt injecte l’IP source d’origine dans un en-tête (send-proxy-v2), et le HAProxy du serveur la lit (accept-proxy). L’IP réelle du visiteur remonte ainsi intacte jusqu’aux logs des services.
La configuration côté OpenWrt
C’est le cœur du dispositif. HAProxy écoute sur le 443, attend d’avoir reçu le ClientHello, en extrait le SNI, et le convertit en nom de backend via un fichier .map.
global
maxconn 2000
defaults
mode tcp
timeout connect 5s
timeout client 50s
timeout server 50s
frontend ft_sni
bind *:443
mode tcp
tcp-request inspect-delay 5s
tcp-request content accept if { req.ssl_hello_type 1 }
use_backend %[req.ssl_sni,lower,map(/etc/haproxy/sni_backend.map,bk_default)]
backend bk_srvA
mode tcp
server srvA 192.168.1.201:443 send-proxy-v2
backend bk_srvB
mode tcp
server srvB 192.168.1.200:443 send-proxy-v2
backend bk_default
mode tcp
server srvA 192.168.1.201:443 send-proxy-v2
Le tcp-request inspect-delay laisse à HAProxy le temps de recevoir le ClientHello avant de décider ; sans lui, il aiguillerait à l’aveugle. Le fichier de correspondance, lui, est trivial à maintenir — une ligne par service :
# /etc/haproxy/sni_backend.map
oo.exemple.com bk_srvA
mail.exemple.com bk_srvB
cloud.exemple.com bk_srvA
git.exemple.com bk_srvB
Ajouter un service, c’est ajouter une ligne et recharger. Pas de reconstruction, pas de redémarrage brutal.
La configuration côté serveurs
Chaque serveur reçoit le flux relayé et termine le TLS. Le point à ne pas manquer : accept-proxy sur le bind, en miroir du send-proxy-v2 du routeur. Si l’un des deux manque, la poignée de main casse avec une erreur cryptique du genre SSL wrong version number — parce que le serveur essaie d’interpréter l’en-tête PROXY comme du TLS, ou l’inverse.
frontend ft_https
bind *:443 accept-proxy ssl crt /etc/haproxy/certs/
mode http
# ... use_backend selon le Host, comme d'habitude
À partir d’ici, on est en terrain connu : mode http, aiguillage par en-tête Host, et le reste de la configuration HAProxy habituelle.
Les certificats : wildcard en DNS-01
Avec une seule IP et du TCP relayé, la validation HTTP-01 de Let’s Encrypt devient pénible à orchestrer. Je suis passé en DNS-01, qui a un autre avantage : elle permet les certificats wildcard. Un seul certificat *.exemple.com couvre tous les sous-domaines, et je n’ai plus à demander un cert par service.
J’utilise certbot avec le plugin OVH, installé dans un venv dédié :
python3 -m venv /opt/certbot
/opt/certbot/bin/pip install certbot certbot-dns-ovh
ln -s /opt/certbot/bin/certbot /usr/local/bin/certbot
Un deploy hook reconstruit le fichier attendu par HAProxy (qui veut fullchain et privkey concaténés dans un même .pem) et recharge le service :
# /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
#!/bin/sh
DOM=exemple.com
cat /etc/letsencrypt/live/$DOM/fullchain.pem \
/etc/letsencrypt/live/$DOM/privkey.pem \
> /etc/haproxy/certs/$DOM.pem
systemctl reload haproxy
L’accès interne : le split-DNS
Depuis Internet, tout fonctionne : le DNS public pointe les sous-domaines vers l’IP publique, la box NAT vers OpenWrt, le SNI aiguille. Mais depuis mon propre réseau, faire un aller-retour par l’IP publique déclenche le hairpin NAT de la box — au mieux c’est lent, au pire ça ne passe pas.
La solution est le split-DNS : sur le réseau local, les mêmes noms doivent résoudre vers l’IP interne du routeur, pas vers l’IP publique. Dans AdGuard Home, une réécriture wildcard suffit :
*.exemple.com → 192.168.1.20
OpenWrt (via dnsmasq) transmet ses requêtes à AdGuard, qui applique la réécriture pour le trafic interne et laisse tout le reste filer normalement. Résultat : depuis le LAN, cloud.exemple.com tape directement OpenWrt en local ; depuis l’extérieur, il passe par l’IP publique. Le même nom, deux chemins, selon d’où l’on vient.
Les pièges qui m’ont coûté des heures
C’est là que se cache la vraie valeur de ce retour d’expérience. Sur le papier, l’architecture est propre. Dans la réalité, cinq embûches m’ont retardé — et aucune ne renvoyait un message d’erreur clair.
1. maxconn trop élevé, et HAProxy refuse de démarrer sur OpenWrt. Une valeur par défaut copiée d’un serveur costaud fait exploser la mémoire allouée pour les descripteurs de fichiers sur l’embarqué. HAProxy meurt au démarrage, sans dire clairement pourquoi. Passer à maxconn 2000 a suffi. Sur un routeur, on dimensionne à la mesure de la machine.
2. Le port 443 rejeté par le pare-feu d’OpenWrt. La box redirige bien vers OpenWrt, mais le trafic arrive sur le routeur à destination du routeur lui-même — il faut donc une règle explicite qui autorise le 443 en entrée sur la zone WAN. Sans elle, la redirection de la box tombe dans le vide, silencieusement.
3. rebind_protection de dnsmasq qui avale les réponses en IP privée. C’est le piège le plus vicieux. Par sécurité, dnsmasq rejette par défaut les réponses DNS pointant vers des IP privées quand elles viennent de l’extérieur — la fameuse protection anti-DNS-rebinding. Sauf que mon split-DNS renvoie justement des IP privées. AdGuard répondait correctement, dnsmasq jetait la réponse en route. Il faut déclarer le domaine en exception (rebind_domain) pour qu’il laisse passer.
4. Un dot.conf résiduel dans systemd-resolved. Sur mon poste, un fichier de configuration oublié forçait tout le DNS à passer par un serveur DoT devenu injoignable. Symptôme : des résolutions incohérentes, parfois l’IP publique, parfois rien, alors que la chaîne côté serveur était impeccable. Le problème n’était pas dans l’infra, mais dans le client. Une leçon que je réapprends à chaque fois : quand le serveur semble bon, suspecter le resolver local.
5. Deux certbot installés, et le renouvellement qui casse en silence. J’avais un certbot en venv (avec le plugin OVH) et un autre installé via apt. Le timer systemd, lui, appelait le certbot d’apt — celui qui n’a pas le plugin dns-ovh. Tout marchait au déploiement manuel, mais le renouvellement automatique aurait échoué trois mois plus tard, sans prévenir. J’ai supprimé la version apt et mis en place une tâche cron ciblant explicitement le binaire du venv. Ce genre de bombe à retardement ne se voit qu’au moment où le certificat expire — autant la désamorcer tout de suite.
Les limites à connaître
Deux choses ne marchent pas, et il vaut mieux le savoir d’avance.
Le HTTPS via noms courts est impossible. Je peux faire un ping pixel grâce au search domain distribué par DHCP, mais pas un https://pixel : le certificat n’a pas pixel tout court dans ses SAN, seulement le FQDN complet. C’est une contrainte du TLS, pas de l’architecture.
Le split-DNS doit être irréprochable. La moindre entrée obsolète dans AdGuard, et l’accès interne renvoie vers une vieille IP tandis que l’externe fonctionne parfaitement — de quoi croire à un bug d’infra alors que c’est juste une réécriture DNS périmée.
En résumé
Le routage SNI en mode TCP est, à mon sens, la façon la plus propre de servir plusieurs serveurs derrière une seule IP publique quand on veut garder une terminaison TLS décentralisée, chaque machine restant maîtresse de ses certificats. Le routeur reste bête et sûr — il n’a jamais une clé privée en main. Ajouter un service se résume à une ligne dans une map.
Le vrai coût n’est pas dans l’architecture, qui est simple une fois comprise. Il est dans la couche de pièges silencieux — pare-feu, protection anti-rebinding, resolver local, doublon de certbot — où rien ne renvoie d’erreur explicite et où il faut débusquer la faille à la main. J’espère que ce retour vous en épargnera quelques-uns.
Laisser un commentaire