OpenVPN dans Docker : le contrat d’exécution dont un client minimal a besoin
Une image vérifiée, un profil protégé, l’accès à TUN et NET_ADMIN. Ce que le conteneur reçoit, ce qu’il ne doit pas recevoir et comment prouver que le tunnel fonctionne.
Placez un client VPN dans un conteneur et vous obtenez un espace de noms avec un tunnel à l’intérieur, pas un hôte qui achemine tout son trafic par un VPN. Ce sont des projets distincts, et les traiter comme un seul est justement la façon dont les conteneurs finissent privilégiés par accident. Ce guide ne couvre que le premier : une image vérifiée, un profil protégé et un processus client confiné à son propre espace de noms réseau.
Partez d’un artefact que quelqu’un a déjà approuvé. Les étapes ci-dessous supposent une image contenant l’exécutable openvpn et un profil client complet dont le certificat et le matériel de clé sont déjà résolus à l’intérieur du montage. Elles ne construisent pas de logiciel, n’émettent pas d’identifiants et ne couvrent ni les environnements sans root ni les conteneurs Windows natifs, chacun changeant la réponse.
Ce que le conteneur reçoit et ce qu’il ne reçoit pas
Gardez le profil et tout ce à quoi il renvoie dans un répertoire local aux permissions restreintes, monté en lecture seule. Les chemins relatifs du profil doivent se résoudre dans ce montage ; c’est un détail que les gens manquent lorsqu’un certificat se trouve un répertoire au-dessus du profil et que le conteneur ne le voit jamais.
services:
vpn:
image: ${VPN_CLIENT_IMAGE:?set the reviewed image reference}
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- ./vpn:/vpn:ro
working_dir: /vpn
entrypoint: ["openvpn"]
command: ["--config", "/vpn/client.ovpn"]
Ce fichier accorde trois choses et ne demande rien d’autre. La référence de l’image doit être épinglée par digest si votre politique de déploiement l’exige, et le profil doit être celui vérifié, pas une copie que quelqu’un a modifiée sur place sur l’hôte.
Lecture seule ici signifie que le conteneur ne peut pas modifier vos fichiers sur l’hôte ; cela ne signifie pas qu’il ne peut pas lire la clé privée que vous avez montée. C’est tout l’intérêt du montage, et c’est aussi pourquoi les permissions de fichiers, la propriété et la façon dont le secret est arrivé sur l’hôte comptent encore. Ne passez jamais un mot de passe sur la ligne de commande et ne l’intégrez jamais dans une couche d’image, car les deux survivent à des endroits que vous ne pourrez pas nettoyer plus tard.
Capacités, entrypoint et le piège entre les deux
Deux réglages sont nécessaires et ils font des travaux sans rapport. NET_ADMIN autorise la manipulation des interfaces et des routes dans l’espace de noms du conteneur. L’entrée de périphérique /dev/net/tun permet au processus d’ouvrir le périphérique de tunnel, ne serait-ce que cela. Accorder l’un sans l’autre produit un échec net, ce qui est pratique : un périphérique TUN absent échoue à l’ouverture, tandis qu’une capacité absente échoue lorsque le client tente de configurer l’interface.
Ni l’un ni l’autre ne justifie privileged: true. Un conteneur privilégié reçoit toutes les capacités plus l’accès aux périphériques, et c’est une concession bien plus large que ce dont cette charge de travail a besoin. La référence sur les privilèges et capacités d’exécution explique jusqu’où va cette concession. Si un chart ou un billet de blog le demande, vérifiez d’abord si l’exigence n’était pas en réalité un chemin de périphérique manquant ou une capacité manquante.
La substitution de entrypoint est la partie qui mord. Appeler openvpn directement est correct pour une image ordinaire et faux pour toute image produit dont le script de démarrage prépare des règles de pare-feu, des fichiers de résolution ou des identifiants avant d’exécuter le démon. Substituer ce script saute la préparation et vous laisse déboguer un tunnel monté dans un espace de noms à moitié configuré.
Exécutez le client au premier plan et laissez-le être le processus principal du conteneur. Un processus qui se démonise sort immédiatement, le conteneur redémarre en boucle, et les journaux dont vous avez besoin sont répartis entre des redémarrages qui se ressemblent tous.
Vérifiez depuis l’intérieur de l’espace de noms, pas depuis l’hôte
Montez la pile, confirmez dans le journal que le client a atteint un état connecté, puis inspectez l’espace de noms qui contient réellement le tunnel.
docker compose up -d
docker compose logs --tail 40 vpn
docker compose exec vpn ip -br addr
docker compose exec vpn ip route
La liste d’adresses devrait montrer une interface de tunnel avec une adresse issue du pool, et la table de routage devrait montrer les destinations poussées pointant vers elle. Si l’interface manque, le client n’a jamais atteint le stade de sa création. Si elle existe mais qu’aucune route ne l’utilise, c’est du côté du push ou des options de route qu’il faut chercher.
Testez ensuite la destination qui vous importe, depuis l’espace de noms censé utiliser le tunnel, avec l’application et pas seulement un ping. La résolution de noms est une défaillance distincte qui mérite un test explicite : une adresse DNS poussée ne met pas à jour à elle seule le résolveur d’un conteneur, et un client autonome qui ignore le push résoudra les noms via ce que l’image a configuré.
L’état déconnecté fait partie du contrat
Le comportement de reconnexion mérite sa propre vérification, car une politique de redémarrage prouve que le processus redémarre et ne dit rien de ce que fait le trafic entre-temps. Arrêtez le tunnel, essayez la destination prévue et observez si la connexion est refusée, se bloque ou réussit silencieusement par la route ordinaire du conteneur. Le dernier résultat est celui qui compte, et il est invisible dans un journal sain.
Si la charge de travail ne doit jamais emprunter la voie ordinaire, concevez-le explicitement avec des règles de pare-feu et un signal de disponibilité dans l’espace de noms. Un kill switch est une politique que vous écrivez et testez, pas une propriété que vous héritez en exécutant un client dans un conteneur.
Enfin, rappelez-vous la frontière : ces routes vivent dans l’espace de noms du conteneur et ne remplacent pas la table de routage de l’hôte. Les règles de transfert, de traduction et de pare-feu de Docker restent une partie du chemin extérieur, et un port publié n’est pas une interface VPN.