Cloudflare Tunnel : publier mon site depuis mon homelab sans ouvrir un seul port
Mon portfolio tourne sur une VM de mon homelab, et aucun port n'est ouvert chez moi. Le principe du tunnel sortant, la config, ce que j'ai verrouillé autour et les pièges rencontrés.
6 min de lecture#cloudflare #homelab #networking #nextjs
Ce site tourne sur une machine virtuelle chez moi, dans mon homelab. Pourtant, aucun port n'est ouvert sur ma box ni sur mon pare-feu : scannez mon adresse IP, rien ne répond. Je croyais qu'héberger un site chez soi voulait forcément dire rediriger le port 443 vers un serveur. Cloudflare Tunnel inverse le sens de la connexion, et c'est ce que je n'avais pas compris avant de le mettre en place.
Le réflexe habituel
La méthode classique : une redirection du port 443 sur la box, un reverse proxy, un certificat Let's Encrypt, et l'IP de la maison dans le DNS du domaine. Ça marche, mais tout internet peut alors parler directement au reverse proxy et à l'application. La moindre faille dans l'un des deux devient un chemin vers mon réseau.
Chez moi, la règle est simple : le seul port ouvert est celui du VPN. Je voulais publier le portfolio sans y toucher, et qu'il ne réponde que par son nom de domaine, jamais par l'adresse IP.
Le principe : c'est la VM qui appelle
cloudflared, le client de Cloudflare, tourne sur la VM du site. Au démarrage, il ouvre lui-même des connexions sortantes vers le réseau de Cloudflare : quatre connexions en QUIC sur le port 7844, vers des datacenters parisiens dans mon cas. Quand quelqu'un demande ambroise-krzanowski.fr, Cloudflare fait passer la requête dans l'une de ces connexions déjà ouvertes, et cloudflared la transmet à l'application, qui n'écoute que sur 127.0.0.1:3000.
visiteur ──HTTPS──▶ Cloudflare
▲
║ tunnel sortant (7844),
║ ouvert par la VM
║
cloudflared ──▶ 127.0.0.1:3000Rien n'écoute donc sur mon IP publique. Le certificat HTTPS est celui de Cloudflare, je n'ai rien à renouveler. Et le DNS du site ne contient pas mon adresse : ambroise-krzanowski.fr pointe vers le tunnel.
La condition : le DNS du domaine doit être géré par Cloudflare, et l'offre gratuite suffit. J'ai remplacé les serveurs de noms chez IONOS, qui reste mon bureau d'enregistrement et mon hébergeur mail.
La zone DNS : le piège du nuage orange
À l'import, Cloudflare a récupéré mes enregistrements… et les a mis en « proxifié » par défaut. Y compris vpn (WireGuard), mc (Minecraft) et _dmarc. Or le proxy de Cloudflare ne relaie que du HTTP(S) : laissés ainsi, ils coupaient mon VPN, donc mon propre accès au homelab, le serveur Minecraft et la vérification DMARC de mes mails. Règle à retenir : nuage gris (« DNS only ») pour tout ce qui n'est pas un site web.
Le scan a aussi raté mes deux enregistrements DKIM et un sous-domaine. J'avais listé l'ancienne zone par requêtes DNS avant de toucher à quoi que ce soit, et c'est en comparant ligne par ligne que je les ai vus. Sans les DKIM, mes mails seraient partis avec une signature que personne n'aurait pu vérifier.
Le tunnel, en quatre commandes
Cloudflare propose de gérer le tunnel depuis son tableau de bord Zero Trust. J'ai préféré un tunnel géré localement : la config tient dans un fichier que je versionne.
# login prints a link: authorize the zone in the browser
cloudflared tunnel login
cloudflared tunnel create portfolio
cloudflared tunnel route dns portfolio ambroise-krzanowski.fr
cloudflared tunnel route dns portfolio www.ambroise-krzanowski.frLes règles d'entrée décident de ce qui passe :
tunnel: <tunnel-id>
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: ambroise-krzanowski.fr
path: ^/api/(admin|auth)(/|$)
service: http_status:404
- hostname: www.ambroise-krzanowski.fr
path: ^/api/(admin|auth)(/|$)
service: http_status:404
- hostname: ambroise-krzanowski.fr
service: http://127.0.0.1:3000
- hostname: www.ambroise-krzanowski.fr
service: http://127.0.0.1:3000
- service: http_status:404L'API d'administration ne sort jamais : elle répond 404 dès Cloudflare, avant d'atteindre le serveur. La dernière règle renvoie 404 à tout autre nom d'hôte. L'application vérifie aussi l'en-tête Host et répond 421 à tout ce qui n'est pas le domaine : deux rideaux valent mieux qu'un. cloudflared tunnel ingress rule <url> dit quelle règle s'applique à une URL, pratique pour tester avant de redémarrer.
Ce que j'ai verrouillé autour
Un tunnel n'est pas un pare-feu : une faille dans l'application resterait exploitable à travers lui. Alors :
- La VM est dans une DMZ sur mon pfSense. Elle ne peut sortir que vers le DNS et l'heure du pare-feu, en TCP 80/443 pour les mises à jour et en 7844 pour le tunnel. Tout ce qui vise mes réseaux privés est bloqué et journalisé.
- Le service du site n'a accès qu'au loopback (
IPAddressDeny=anyetIPAddressAllow=localhostdans systemd) : même compromis, le processus Node ne peut ouvrir aucune connexion ailleurs. cloudflaredtourne sous son propre compte, pas en root. J'ai écrit l'unité systemd moi-même au lieu d'utilisercloudflared service install:
[Service]
User=cloudflared
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
NoNewPrivileges=yes
ProtectSystem=strict
CapabilityBoundingSet=- Le certificat de compte est supprimé.
tunnel logindépose uncert.pemqui permet de créer des tunnels et des enregistrements DNS sur la zone. Il ne sert qu'à l'installation : une fois le tunnel créé, je l'ai effacé. La VM ne garde que les identifiants du tunnel, qui permettent seulement de le faire tourner. - L'admin du site ne passe jamais par le tunnel : elle ne répond que sur le loopback, par un tunnel SSH, avec le VPN.
Deux surprises
L'IP du visiteur. Derrière le tunnel, toutes les requêtes arrivent de 127.0.0.1. Or mon formulaire de contact limite le débit par adresse. J'ai envoyé une requête avec un faux X-Forwarded-For et capturé ce que recevait l'application :
X-Forwarded-For: 1.2.3.4,<mon IP>
Cf-Connecting-Ip: <mon IP>Cloudflare ajoute la vraie adresse à la fin. Mon code prend le dernier élément de la liste : un en-tête forgé ne contourne pas les limites.
Une 500 qui n'existait qu'en production. Je voulais que /admin affiche une page d'aide sur le domaine public. Une réécriture (NextResponse.rewrite) marchait en local et renvoyait une 500 derrière le tunnel. Dans les logs :
Failed to proxy https://localhost:3000/acces-admin
Error: write EPROTONext.js voit sa propre origine comme https://localhost : le protocole vient de Cloudflare, et il remplace 127.0.0.1 par localhost. Son routeur, lui, compare avec 127.0.0.1. Il a donc pris ma réécriture pour une URL externe et s'est relayé la requête à lui-même… en TLS, sur un port qui parle HTTP. Mon test local passait par chance : en HTTP, ce relais fonctionne. Une redirection a réglé le problème :
// A redirect, not a rewrite: Next names its own origin
// https://localhost here but 127.0.0.1 when routing, so it
// would take a rewrite for an external URL and proxy it to
// itself over TLS (500 behind the tunnel)
if (admin === "page") return NextResponse.redirect(new URL(ADMIN_ACCESS_PAGE, SITE_URL), 307);Les limites
Cloudflare termine le TLS : il voit passer tout le trafic du site en clair. Pour un portfolio public, je l'accepte, et l'admin ne passe pas par là. Le site dépend aussi de Cloudflare pour le DNS et l'acheminement. Et le tunnel ne cache que le site : le VPN et Minecraft pointent toujours vers mon adresse.
Je referais tout pareil : aucun port ouvert, aucun certificat à gérer, une VM qui ne parle qu'à Cloudflare. Ce que je retiens surtout : lister l'ancienne zone DNS avant de basculer, parce que l'import de Cloudflare n'est ni complet ni prudent.