Contexte
Mon serveur homelab fait tourner 28 services. La plupart sont privés, et je voulais un moyen simple d’y accéder depuis l’extérieur. J’ai choisi Tailscale. Voici comment je l’ai mis en place.
Le réseau en dessous
Avant la configuration du VPN, un peu de contexte sur la façon dont le réseau de mon cluster Kubernetes est organisé.
La plupart de mes services sont privés, quelques-uns sont exposés sur internet. Cette séparation m’a donné deux classes de services, chacune a son propre namespace, son réseau et ses règles de sécurité (NetworkPolicies).
Le réseau du cluster repose sur MetalLB pour la gestion des IP et Traefik comme reverse proxy.
Chaque classe obtient une IP virtuelle de MetalLB : .210 pour la publique, .220 pour la privée. Traefik écoute sur les deux et route chaque requête vers le bon service, en fonction de son nom d’hôte.
Le trafic public passe par Cloudflare → redirection de port → Traefik sur .210.
Côté privé, le vrai verrou est topologique : le routeur ne redirige jamais vers .220. Les NetworkPolicies sont de la défense en profondeur, pas la première barrière.
Je voulais un accès distant au cluster, et à tout mon réseau local. J’ai donc fait tourner un VPN dans le cluster.
Résumé du routage :
Internet ── Cloudflare(proxy) ── redir. ports 80/443/25565 ──► 192.168.1.210 traefik-public
LAN ──────────────────────────────────────────────────► 192.168.1.220 traefik-private + CoreDNS (*.server)
Tailnet ── WireGuard/DERP ──► pod subnet-router ── SNAT ────► 192.168.1.220 (identique au LAN)
Pourquoi Tailscale
J’ai commencé avec OpenVPN. Les seules images de conteneurs que j’ai trouvées n’étaient plus maintenues depuis des années, et ne fonctionnaient tout simplement pas.
J’ai donc essayé WireGuard. Cette fois, l’image était correcte. Mais joindre tout mon réseau local depuis un conteneur, avec le DNS logé dans un autre conteneur, s’est révélé être un véritable enfer.
Quelques-uns des problèmes rencontrés :
- Le conteneur a besoin de beaucoup de permissions réseau au niveau de l’hôte
- DNS Kubernetes injoignable depuis la connexion VPN
- Réseau local injoignable depuis la connexion VPN
- Des NetworkPolicies à régler correctement
- Un port à rediriger à la fois sur le routeur et sur le pare-feu
- Des clés d’authentification et de chiffrement à stocker en sécurité, et à distribuer en sécurité sur chaque appareil
- Des noms de paramètres qui changent d’une version à l’autre (une grande partie de la documentation en ligne est obsolète)
- Une configuration très longue
- Un comportement malveillant difficile à repérer et difficile à bloquer avec un pare-feu, puisque le conteneur détient des permissions sur l’hôte
Pour être juste avec WireGuard, la configuration a fini par fonctionner. Mais à quel prix ? Et était-ce sûr de l’exposer sur internet ?
C’est là que j’ai entendu parler de Tailscale : un écosystème VPN propriétaire construit sur WireGuard. La mise en place est bien plus simple. On se connecte, on lui donne les informations de son réseau, on démarre le conteneur, et voilà.
Tout le reste se configure sur le site de Tailscale, et beaucoup de fonctionnalités avancées sont intégrées :
- Filtrage avancé (par système d’exploitation, par exemple)
- Gestion multi-utilisateurs
- Logs
- DNS interne (entièrement personnalisable)
- Tests d’ACL automatisés (ils détectent la faille de sécurité qu’on ouvrirait en désactivant une règle par erreur)
- Un éditeur JSON complet
L’objectif n’était pas de passer des heures à construire un VPN pour homelab. C’est exactement ce que Tailscale m’a apporté.
Le subnet router
Pour permettre au VPN d’atteindre le réseau local, j’ai passé beaucoup de temps à essayer de donner à Tailscale une IP sur le LAN. À chaque tentative, cela cassait l’accès à mes services privés : le routage devenait trop complexe, et filtrer le trafic malveillant plus difficile.
J’ai donc pris le problème dans l’autre sens, et donné à mes clients VPN une IP sur le réseau privé Kubernetes.
Les NetworkPolicies continuent de s’appliquer, donc un client du tailnet n’atteint que ce à quoi il a droit. Pas d’accès à Redis ni à PostgreSQL. Accès à leurs interfaces web, oui.
L’inconvénient : les logs de mon Traefik privé ne montrent jamais quel utilisateur du tailnet a fait quoi. Une seule IP de pod pour tout le monde.
Chaîner les couches DNS
Le DNS a été la partie qui a demandé le plus de travail, parce que j’avais trois résolveurs à chaîner.
Le routeur de mon fournisseur d’accès assurait la résolution générale, avec Quad9 en repli pour la confidentialité. Une instance CoreDNS dans Kubernetes contenait les IP de mes services privés (*.server). Et Tailscale apporte sa propre couche DNS, MagicDNS, qui gère le split DNS : router un domaine précis vers un résolveur personnalisé.
Le chaînage, de haut en bas :
- MagicDNS utilise le split DNS et pointe *.server vers CoreDNS sur .220 (joignable depuis la connexion tailnet, donc ça marche)
- CoreDNS transmet tout le reste au résolveur du fournisseur d’accès
- le routeur du fournisseur d’accès bascule sur Quad9 en repli
Cela donne une chaîne complète, avec une priorité de résolution claire.
Les appareils locaux utilisent CoreDNS (.220) comme résolveur. Les appareils distants récupèrent le leur depuis la configuration Tailscale.
CoreDNS (192.168.1.220) ─┬─ *.server ────────► autoritatif, VIP des services privés
└─ tout le reste ────forward──► DNS du routeur FAI ──secondaire──► Quad9
Cela me permet d’atteindre n’importe quel service privé depuis n’importe lequel de mes appareils. Les locaux via un simple forward DNS :
Appareil local ─────── toutes les requêtes ─────────────────────► 192.168.1.220 CoreDNS
Les distants via la configuration DNS du VPN :
Appareil tailnet ─┬─ *.ts.net ─────► MagicDNS
├─ *.server ─────► split DNS ──► pod subnet-router ── SNAT ──► 192.168.1.220 CoreDNS
└─ tout le reste ─► le résolveur propre à l’appareil
Ne pas oublier d’ouvrir les NetworkPolicies pour le DNS : port 53, UDP et TCP.
Encore un peu de travail manuel
Tailscale est simple à mettre en place côté serveur. Mais certains réglages n’existent que dans l’interface de Tailscale, alors je les ai configurés à la main, une fois pour toutes :
- ACL
- tagOwners
- Approbation des routes
- RBAC sur le proxy apiserver
- Groupes
- Filtrage
- DNS
- et d’autres
Voici une configuration de base pour une installation Kubernetes. Le bloc grants est celui par défaut de Tailscale et autorise tout : le garder restreint aux tags, ne jamais l’ouvrir à tous les utilisateurs.
// Exemple / ACL par défaut pour des connexions sans restriction.
{
// Définit les tags applicables aux appareils et par quels utilisateurs.
"tagOwners": {
"tag:k8s-operator": [],
"tag:k8s": ["tag:k8s-operator"],
},
"autoApprovers": {
"routes": {
"192.168.1.0/24": ["tag:k8s"],
"10.1.0.0/16": ["tag:k8s"],
"10.152.183.0/24": ["tag:k8s"],
},
},
// Définit les grants qui régissent l’accès pour les utilisateurs, groupes,
// autogroups, tags, adresses IP Tailscale et plages de sous-réseaux.
"grants": [
// Autorise toutes les connexions.
// Commenter cette section pour définir des restrictions spécifiques.
{"src": ["*"], "dst": ["*"], "ip": ["*"]}
],
// Définit les utilisateurs et appareils autorisés à utiliser Tailscale SSH.
"ssh": [
// Autorise tous les utilisateurs à se connecter en SSH à leurs propres
// appareils en mode check.
// Commenter cette section pour définir des restrictions spécifiques.
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"],
},
],
}
Je n’ai rien automatisé. Terraform pourrait le faire, mais cela ressemblait à de la sur-ingénierie pour une configuration à faire une seule fois.
Conclusion
Tailscale est le choix que je referais. Bien plus simple à mettre en place que ses concurrents open source, et plus facile à sécuriser correctement. L’interface aide aussi : un vrai éditeur JSON, et des tests d’ACL intégrés qui attrapent les erreurs avant qu’elles ne partent en production.
Deux choses que j’ai sacrifiées : l’attribution par appareil dans mes logs privés, et une partie de la configuration qui ne vit pas dans mon dépôt. Les deux en valaient la peine. Cela ne les rend pas gratuites pour autant.