Aller au contenu

HAProxy, deux domaines et deux VM sur un seul Proxmox

Comment j'ai routé deux noms de domaine vers deux VM Proxmox avec une seule IP publique, grâce à HAProxy dans un LXC, au SNI et à un filtrage Cloudflare.

Self hosting & homelab9 min de lecture

HAProxy devant un serveur Proxmox

Ça faisait un moment que je voulais un playground, une VM à part avec son propre Dokploy et son propre domaine, où je peux tester, casser et réinstaller sans jamais risquer de faire tomber mes vrais services. J'ai un Proxmox chez OVH avec de la place, donc je me suis dit « facile, je monte une VM ». Sauf que je n'ai qu'une seule IP publique, et que mes ports 80 et 443 partaient déjà tous vers mon Dokploy de prod. Spoiler, pas besoin d'acheter une deuxième IP. Un petit LXC avec HAProxy dedans a suffi, avec quand même deux ou trois petits pièges.

Le point de départ#

Sur mon Proxmox, les VM sont dans un réseau privé 10.0.0.0/24 derrière le bridge vmbr1, et mon hôte fait du NAT avec iptables. Tout le trafic web partait vers le conteneur qui fait tourner mon Dokploy, en 10.0.0.74.

/etc/iptables/rules.v4
-A PREROUTING -d 203.0.113.10/32 -p tcp --dport 80  -j DNAT --to-destination 10.0.0.74:80
-A PREROUTING -d 203.0.113.10/32 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.74:443
 
# Seul Cloudflare a le droit de parler aux ports 80 et 443
-A FORWARD -i vmbr0 -d 10.0.0.74/32 -p tcp -m multiport --dports 80,443 -m set ! --match-set cf4 src -j DROP

nikho.dev passe par le proxy de Cloudflare, et la dernière règle jette tout ce qui ne vient pas d'une IP Cloudflare (l'ipset cf4). Personne ne peut donc contourner Cloudflare en tapant directement sur l'IP du serveur.

Le problème est simple. Un port redirigé ne peut pointer que vers une seule machine. Pour ajouter mon playground, il me fallait donc quelque chose devant les deux VM, capable de savoir à qui s'adresse chaque requête.

Une seule IP suffit, grâce au SNI#

Sur le port 80, c'est facile. La requête HTTP est en clair, il suffit de lire l'en-tête Host pour savoir où l'envoyer.

Sur le 443, on pourrait croire qu'il faut déchiffrer le trafic pour savoir à qui il est destiné. En fait non. Au tout début d'une connexion TLS, le navigateur envoie un ClientHello qui contient le nom de domaine demandé, en clair. C'est le SNI (Server Name Indication). HAProxy lit ce champ, choisit la bonne VM, puis relaie le flux chiffré tel quel, sans jamais le déchiffrer.

Ça veut dire que chaque VM garde ses propres certificats Let's Encrypt, gérés par son Dokploy, exactement comme avant. Rien à migrer.

Pourquoi HAProxy#

HAProxy, c'est un reverse proxy et un load balancer open source qui existe depuis plus de vingt ans, et qui tourne devant des sites comme GitHub ou Stack Overflow. Il est réputé pour ses performances, un seul processus encaisse des dizaines de milliers de connexions en consommant très peu de CPU et de mémoire. Pour mon usage, il ne fait que lire le SNI et relayer le flux, autant dire qu'il ne travaille quasiment pas.

J'aurais pu faire la même chose avec Nginx et son module stream, ou avec Traefik, mais HAProxy fait exactement ça, simplement, avec une config lisible et une page de stats intégrée.

Pourquoi un LXC dédié#

J'ai hésité à installer HAProxy directement sur l'hôte Proxmox. Mauvaise idée, au final. HAProxy reçoit tout le trafic d'Internet, et une faille dedans donnerait directement la main sur l'hyperviseur, donc sur toutes les VM.

Une VM complète pour un HAProxy aurait été du gaspillage. Un LXC Debian non privilégié avec 1 vCPU et 512 Mo fait très bien l'affaire, et se snapshot en un clic avant chaque modif.

Créer le LXC#

J'ai utilisé le script HAProxy de community-scripts, lancé depuis le shell du nœud Proxmox, en mode Advanced pour régler le réseau à la main.

  • Container non privilégié.
  • Bridge vmbr1, le réseau privé, surtout pas vmbr0 qui est relié à l'IP publique.
  • IP statique 10.0.0.77/24, avec la passerelle 10.0.0.1 (l'IP de vmbr1 sur l'hôte).

La config HAProxy#

Une subtilité avant de regarder la config. Le domaine du playground n'est pas chez Cloudflare mais chez un autre provider, il pointe donc directement sur mon IP. Si je gardais le filtre Cloudflare dans iptables, toutes ses requêtes seraient jetées. Le filtrage passe donc dans HAProxy, qui voit à la fois l'IP du visiteur et le domaine demandé.

  • nikho.dev n'est accepté que depuis Cloudflare.
  • playground.example est accepté depuis n'importe où.

Voici la config complète que j'utilise.

/etc/haproxy/haproxy.cfg
defaults
        log global
        mode http
        option httplog
        timeout connect 5s
        timeout client 50s
        timeout server 50s
        timeout tunnel 1h
 
# Port 443 : routage sur le SNI, sans déchiffrer
frontend https_in
        bind *:443
        mode tcp
        option tcplog
        tcp-request inspect-delay 5s
        acl is_playground req.ssl_sni -i playground.example
        acl is_playground req.ssl_sni -m end .playground.example
        acl from_cf       src -f /etc/haproxy/cloudflare.lst
        acl tls_hello     req_ssl_hello_type 1
        tcp-request content accept if tls_hello is_playground
        tcp-request content accept if tls_hello from_cf
        tcp-request content reject
        use_backend playground_https if is_playground
        default_backend nikho_https
 
backend nikho_https
        mode tcp
        server dokploy 10.0.0.74:443 check
 
backend playground_https
        mode tcp
        server playground 10.0.0.76:443 check
 
# Port 80 : routage sur l'en-tête Host
frontend http_in
        bind *:80
        acl is_playground hdr(host) -i playground.example
        acl is_playground hdr_end(host) -i .playground.example
        acl from_cf       src -f /etc/haproxy/cloudflare.lst
        http-request deny if !is_playground !from_cf
        use_backend playground_http if is_playground
        default_backend nikho_http
 
backend nikho_http
        server dokploy 10.0.0.74:80 check
 
backend playground_http
        server playground 10.0.0.76:80 check

Quelques lignes méritent une explication.

  • tcp-request inspect-delay laisse à HAProxy le temps de recevoir le ClientHello avant de décider.
  • req_ssl_hello_type 1 vérifie qu'il s'agit bien d'un ClientHello TLS.
  • Les deux ACL is_playground couvrent le domaine nu et tous ses sous-domaines.
  • Les deux accept laissent passer le playground depuis partout et le reste uniquement depuis Cloudflare. Le reject final coupe tout ce qui n'a pas été accepté.
  • default_backend envoie tout le reste vers mon Dokploy de prod. Tout ce qui tournait déjà continue donc de marcher sans que j'y touche.
  • timeout tunnel 1h évite de couper les WebSockets inactifs au bout de 50 secondes.

Le fichier cloudflare.lst contient les plages d'IP de Cloudflare, et un petit cron hebdomadaire le tient à jour.

/etc/cron.weekly/cloudflare-ips
#!/bin/sh
set -e
tmp=$(mktemp)
curl -fsS https://www.cloudflare.com/ips-v4 > "$tmp"
if [ -s "$tmp" ]; then
    chmod 644 "$tmp"
    mv "$tmp" /etc/haproxy/cloudflare.lst
    haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy
else
    rm -f "$tmp"
fi

On valide toujours la config avant de recharger.

haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy

Tester avant de basculer#

Le gros avantage de passer par un LXC, c'est qu'on peut tout tester sans toucher à la prod. Depuis l'hôte, curl --resolve force le nom de domaine vers l'IP du LXC.

curl -skI --resolve nikho.dev:443:10.0.0.77 https://nikho.dev

Ici la requête vient de l'hôte, pas de Cloudflare, donc HAProxy doit couper la connexion. C'est exactement ce qu'on veut vérifier, le filtre fonctionne avant même d'avoir redirigé le moindre port.

Basculer sans rien casser#

Il ne reste plus qu'à faire pointer les ports 80 et 443 vers le LXC.

cp /etc/iptables/rules.v4 /etc/iptables/rules.v4.bak
 
iptables -t nat -I PREROUTING 1 -d 203.0.113.10/32 -p tcp --dport 80  -j DNAT --to-destination 10.0.0.77:80
iptables -t nat -I PREROUTING 1 -d 203.0.113.10/32 -p tcp --dport 443 -j DNAT --to-destination 10.0.0.77:443

Les nouvelles règles sont insérées en tête, elles passent donc devant les anciennes. Si quelque chose casse, il suffit de les supprimer avec -D pour revenir instantanément à l'état d'avant. Une fois que tout marche, on supprime les anciennes redirections vers 10.0.0.74, et surtout on n'oublie pas le iptables-save > /etc/iptables/rules.v4, sinon tout disparaît au prochain reboot.

Dernière vérification, depuis l'extérieur cette fois. Une requête vers nikho.dev qui tape directement sur l'IP du serveur doit être coupée net.

curl -skI --resolve nikho.dev:443:203.0.113.10 https://nikho.dev

La page de stats de HAProxy confirme que le trafic passe bien par lui.

# pxname            svname      scur  stot  status
https_in            FRONTEND    47    48    OPEN
nikho_https         dokploy     47    48    UP
playground_https    playground  0     0     DOWN

Le backend du playground est DOWN, normal, la VM n'existait pas encore.

Une VM pour le playground, pas un LXC#

Un playground, c'est l'endroit où je vais installer n'importe quoi trouvé sur Internet, lancer des conteneurs douteux et tout casser régulièrement. Pour HAProxy, un LXC était parfait, mais ici la question méritait d'être posée.

Un LXC partage le noyau de l'hôte. Si quelque chose arrive à s'en échapper, c'est directement l'hyperviseur qui est touché, et donc toutes les autres machines. Ça passe pour un service que je maîtrise, beaucoup moins pour une machine dont le but est justement d'accueillir des trucs que je ne connais pas encore. En plus, Proxmox déconseille officiellement Docker dans un LXC, et les mises à jour de l'hôte cassent régulièrement Docker Swarm dedans. Pour le playground, ce sera donc une vraie VM, avec son propre noyau.

  • Machine q35, CPU type host, 2 cœurs et 4 Go de RAM.
  • Bridge vmbr1, IP 10.0.0.76/24.
  • Debian 13 en cloud-init, puis le script d'installation de Dokploy.

Dès que Dokploy tourne sur la VM, le backend passe UP dans les stats, et le playground répond avec son certificat Let's Encrypt.

Les limites#

Ce setup me convient très bien, mais il a quelques limites à connaître.

  • L'IP des visiteurs est perdue. Vues des VM, toutes les connexions viennent du LXC. Pour nikho.dev, l'en-tête CF-Connecting-IP de Cloudflare compense. Pour le playground, il faudrait activer le PROXY protocol, send-proxy-v2 côté HAProxy et l'option équivalente côté Traefik, des deux côtés en même temps, sinon plus rien ne passe.
  • Le LXC est un point unique de défaillance. S'il tombe, les deux domaines tombent. Pensez à cocher le démarrage automatique.

En bref#

  • Une seule IP publique suffit pour plusieurs VM, à condition de mettre un HAProxy devant.
  • Le port 80 se route sur l'en-tête Host, le 443 sur le SNI, sans jamais déchiffrer le trafic. Les certificats restent gérés par chaque VM.
  • Le filtrage Cloudflare peut se faire dans HAProxy, domaine par domaine, avec une liste blanche et un reject final.
  • Un LXC dédié protège l'hyperviseur, mais pour une machine où l'on installe n'importe quoi, une VM reste le bon choix.

Bref, avec un LXC qui ne consomme presque rien et une quarantaine de lignes de config, j'ai enfin mon playground sur son propre domaine, et ma prod n'a pas bougé. Le jour où j'ai besoin d'une troisième VM, ce sera deux ACL et deux backends de plus.