Comment acheminer uniquement le trafic des conteneurs Docker via OpenVPN
Gardez l'hôte sur son uplink habituel et placez un seul sous-réseau de conteneurs dans le tunnel : ordre NAT, règles de source, iroute OpenVPN et contrôles MSS.
Le paquet du conteneur n'atteint jamais tun0, et des capacités supplémentaires sur le conteneur n'y changeront rien. Deux recherches de route se dressent entre l'invité et le tunnel : une dans l'espace de noms réseau de l'invité, une sur l'hôte une fois la trame arrivée sur le pont, et la seconde consulte une autre table avec d'autres règles de correspondance. Ma disposition de référence est un hôte Linux exécutant un OpenVPN client qui détient 10.8.0.10 sur tun0, un pont Docker sur 172.18.0.0/16, et une adresse privée que seul le côté tunnel peut joindre. L'objectif reste étroit : ce sous-réseau invité sort par le tunnel tandis que l'hôte conserve son uplink habituel.
Exécuter le client VPN dans le conteneur est une autre conception, pas un raccourci. Le conteneur possède alors son tunnel et ses identifiants, et le travail passe dans un Dockerfile au lieu de rester dans iptables.
Le transfert choisit une route par destination, puis NAT réécrit la source
Avec net.ipv4.ip_forward=1 défini, un paquet transféré est routé uniquement par destination, dans la table principale. La source ne joue aucun rôle dans cette décision. Ce n'est qu'après le choix de la route que le noyau exécute POSTROUTING, et c'est là que se trouve la règle propre à Docker : -s 172.18.0.0/16 ! -o br-appnet -j MASQUERADE. Tout ce qui quitte le pont vers une autre interface est traduit, et l'adresse qu'il devient dépend de l'interface vers laquelle la route l'a envoyé.
Cette règle explique la plupart des symptômes qu'on me décrit. Si la table principale n'a pas de route pour la destination privée, le paquet sort par l'uplink avec l'adresse physique de l'hôte. Votre fournisseur jette la destination, ou le site distant jette une source RFC 1918 qu'il n'attendait pas, et le conteneur expire alors que le tunnel reste levé et sain. Une capture sur tun0 ne montre rien, et c'est l'indice. Si la route pointe bien vers tun0, MASQUERADE réécrit la source en 10.8.0.10 et le même conteneur fonctionne sans que vous touchiez à quoi que ce soit.
Demandez la recherche qui nomme la source de l'invité. Sans from et iif l'hôte répond pour sa propre socket et signale un chemin que le paquet transféré ne prendra jamais :
ip route get 192.168.11.222 from 172.18.0.5 iif br-appnetSi cette réponse nomme l'uplink, le tunnel n'est pas en cause et aucun réglage côté tunnel n'aidera.
Amenez l'invité sur l'adresse propre du tunnel
Le plus petit changement qui fonctionne est une réécriture explicite de la source à la sortie. Insérez-la au lieu de l'ajouter : Docker siège déjà en tête de POSTROUTING, donc une règle ajoutée avec -A est évaluée derrière celle de Docker et ne se déclenche jamais.
iptables -t nat -I POSTROUTING 1 -s 172.18.0.0/16 -o tun0 -j SNAT --to-source 10.8.0.10Deux conditions décident si cela tient. L'adresse doit être une que le client détient réellement, alors épinglez-la avec une entrée par client contenant ifconfig-push 10.8.0.10 255.255.255.0 ; une règle nommant une adresse qui n'a jamais été attribuée produit des rejets silencieux, pas une erreur. Et le côté distant doit accepter cette source et router les réponses en retour, c'est pourquoi une liste d'autorisation y nomme l'adresse du tunnel plutôt que le sous-réseau invité. Quand l'adresse du client est dynamique, -j MASQUERADE avec -o tun0 suit ce que l'interface détient, au prix d'une politique distante contre laquelle vous ne pouvez pas écrire de valeur fixe.
Le filtrage est l'autre moitié. Docker met la politique FORWARD à drop et vide les chaînes qu'il possède à chaque redémarrage du démon, alors placez la politique locale dans DOCKER-USER, qui survit :
iptables -I DOCKER-USER -i br-appnet -o tun0 -j ACCEPT
iptables -I DOCKER-USER -i tun0 -o br-appnet -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPTLa règle de retour compte : une réponse arrivant sur tun0 pour une connexion ouverte par le pont n'est pas acceptée simplement parce que la direction sortante l'a été.
Gardez l'hôte direct avec une règle de source
Quand tout le pont doit utiliser le tunnel mais rien d'autre sur l'hôte, sautez NAT et donnez à cette source sa propre table. Les adresses invitées restent alors visibles de bout en bout.
echo "200 vpnbox" >> /etc/iproute2/rt_tables
ip rule add from 172.18.0.0/16 lookup vpnbox pref 1000
ip route add default dev tun0 table vpnboxLes règles sont consultées avant la table principale, et from correspond à la source. Les paquets externes du tunnel portent la source physique de l'hôte, ils ne correspondent donc jamais à from 172.18.0.0/16 et le tunnel ne peut pas se router lui-même vers lui-même. Cette auto-exclusion est ce qui rend la disposition stable.
Le chemin de retour a maintenant besoin d'aide. Le côté distant voit une 172.18.0.x non traduite et n'a aucune raison de savoir où elle vit. Sur le serveur, un fichier par client a besoin de iroute 172.18.0.0 255.255.0.0 pour que la table interne du serveur remette ce préfixe à ce client, et d'un route 172.18.0.0 255.255.0.0 poussé pour que les autres clients sachent qu'il existe.
Deux vérifications avant de lui faire confiance. Lancez ip route show default : avec redirect-gateway def1 la table contient 0.0.0.0/1 et 128.0.0.0/1 via tun0, et l'hôte n'est pas direct du tout, car ces demi-défauts l'emportent sans remplacer default. Les règles ajoutées avec ip rule add disparaissent aussi au redémarrage, alors portez-les dans un script up.
La taille des paquets est le second échec que vous rencontrez
Une interface de pont à 1500 octets donne au tunnel plus qu'il ne peut porter une fois les en-têtes UDP et IP externes ajoutés. Les petits échanges passent, donc DNS résout et la poignée de main TLS se termine, puis un envoi plus gros se bloque sans aucune erreur nulle part. L'émetteur répète la même taille de segment parce que le message ICMP de fragmentation nécessaire, qui lui dirait de réduire, n'arrive jamais.
Corrigez-le au niveau de l'interface, du MSS, ou des deux. Le serrage n'a besoin d'aucune coopération de l'invité :
ip link set dev tun0 mtu 1400
iptables -t mangle -A FORWARD -o tun0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtuLe serrage ne réécrit que TCP. UDP, QUIC compris, a besoin à la place d'une interface invitée plus basse, avec --opt com.docker.network.driver.mtu=1400 à la création du réseau ou la clé compose correspondante.
DNS et les vérifications qui valent d'être répétées
La résolution de noms suit le même chemin. Le résolveur intégré de Docker à 127.0.0.11 transmet aux serveurs configurés de l'hôte, donc un conteneur hérite de ce que l'hôte utilise. Quand la zone privée n'est répondue que par un résolveur joignable via le tunnel, pointez l'invité vers cette adresse, et testez avec getent hosts dans le conteneur plutôt que sur l'hôte.
- Exécutez la requête qui échoue dans l'invité ; un test côté hôte utilise une autre adresse source et masque le problème.
- Lisez les compteurs des règles NAT et de filtrage que vous avez ajoutées. Une règle sans correspondance est une erreur de routage, pas de politique.
- Confirmez que l'adresse du tunnel dans votre règle SNAT est bien une que le client détient.
- Baissez le MTU de l'invité ou serrez le MSS avant de blâmer le serveur pour un blocage.
Parcourez cette liste et arrêtez-vous au premier échec. Cette couche est celle à corriger ; tout ce qui est ajouté plus bas ne fait que masquer la frontière qui casse.