Épingler un seul programme à un VPN sous Linux est surtout un problème de routage, et la règle de pare-feu ne décide que de ce qui se passe une fois la route choisie. Si vous associez l'UID du programme dans OUTPUT avant que le tunnel ne possède la route par défaut, la règle rejette précisément le trafic que vous vouliez protéger. La même règle ne voit pas non plus le processus de résolution qui répond aux requêtes DNS du programme. Mettez l'ordre correct et un seul compte non privilégié devient un sélecteur fiable de tunnel partagé.

Donnez une route au compte avant de juger

La correspondance de propriétaire répond à une question : un socket local appartenant à cet UID a-t-il construit le paquet ? Elle ne choisit pas d'interface et ne peut pas déplacer le trafic que la table principale envoie déjà par la carte réseau physique. C'est pourquoi une règle UID sans route correspondante bloque le programme au lieu de le rediriger.

Faites d'abord passer le compte par sa propre table, puis laissez le pare-feu confirmer le résultat. Cela fonctionne sur un hôte dont iproute2 comprend uidrange, ce que ip rule help signale en une ligne. La table 100 contient une route par défaut via le tunnel, et la règle n'envoie que l'UID 1000 vers cette table. Tout le reste demeure dans la table principale et continue d'utiliser le lien physique.

ip rule add uidrange 1000-1000 table 100
ip route add default dev tun0 table 100

Un second compte qui a besoin d'un autre tunnel reçoit la table 101 et sa propre règle, car deux profils ne peuvent pas posséder tous les deux la route par défaut dans la même table. Les profils NetworkManager sont à l'échelle du système, donc cette partie reste manuelle, et les règles disparaissent au redémarrage si rien ne les restaure. Écrivez les tables dans un script de démarrage à côté de la définition du tunnel pour qu'une reconnexion ne puisse pas laisser un compte sur le mauvais chemin.

Pourquoi l'exception loopback tue le tunnel

La plupart des versions copiées de la règle rejettent chaque paquet qui ne sort pas par loopback. Le tunnel n'est pas loopback, donc cette forme rejette aussi le trafic du tunnel, et le résultat ressemble à un VPN mort plutôt qu'à une erreur de pare-feu.

Au moment où OUTPUT s'exécute, le noyau a déjà choisi une interface de sortie, donc -o est comparé à la réponse de la route et non à l'en-tête du paquet. La correspondance de propriétaire n'est consultée que dans OUTPUT et POSTROUTING, et les paquets transférés ne portent aucun socket qu'elle pourrait lire. Cette combinaison signifie que l'exception doit nommer l'interface du tunnel et nier cette correspondance, plutôt que de nier loopback.

Loopback mérite toujours son propre ACCEPT au-dessus du DROP, car le premier paquet DNS du programme va vers le stub local via lo. Placez les deux règles dans cet ordre et le compte atteint le stub, puis le tunnel, et rien d'autre.

iptables -I OUTPUT 1 -o lo -m owner --uid-owner vpnapp -j ACCEPT
iptables -A OUTPUT -m owner --uid-owner vpnapp ! -o tun0 -j DROP

IPv6 est un ensemble de règles distinct, et une paire limitée à v4 laisse ip6tables intact. Un programme qui préfère les enregistrements AAAA sortirait alors par le lien physique sans jamais atteindre le DROP. Reproduisez les deux règles dans ip6tables, ou désactivez IPv6 sur l'hôte quand le tunnel ne porte aucune adresse v6. Les règles ajoutées à la main disparaissent aussi au redémarrage, donc enregistrez-les avec netfilter-persistent save ou reconstruisez la même logique sous forme de table nftables chargée par l'unité de démarrage.

Les réponses DNS viennent d'un autre processus

La requête du programme vers 127.0.0.53 passe l'ACCEPT loopback, et c'est là que la correspondance UID s'arrête. Le résolveur stub transmet en amont depuis son propre compte de service, donc une règle liée à vpnapp ne voit jamais la seconde requête. Si le résolveur choisit un serveur LAN pour le lien, les réponses reviennent par la carte physique tandis que les paquets du programme restent dans le tunnel. Le résultat visible est un tunnel qui fonctionne avec des noms cassés, ou des noms répondus par un serveur que vous n'avez pas choisi.

Corrigez la résolution de noms sur le lien du tunnel, pas dans l'application. Donnez au profil du tunnel un serveur DNS et un domaine de routage, puis confirmez avec resolvectl query et resolvectl status tun0 que le résolveur l'utilise pour les noms qui comptent. Dans un espace de noms réseau, le problème disparaît, car le résolveur de l'espace ne connaît que le tunnel.

Vérifiez la règle depuis le compte lui-même

Demandez au noyau quelle route reçoit un UID, puis faites une requête en tant que ce compte et comparez l'adresse de sortie avec celle de votre propre compte. Si les deux rapportent la même adresse, la règle correspond à autre chose qu'au trafic que vous avez testé. La première commande ci-dessous montre la recherche de route sans envoyer de paquet, ce qui sépare une erreur de routage d'une erreur de pare-feu.

ip route get 1.1.1.1 uid 1000
sudo -u vpnapp curl -s https://ifconfig.co

Les compteurs montrent si une règle s'est déjà déclenchée. Lancez iptables -vnL OUTPUT, lisez le nombre de paquets à côté de chaque règle, et traitez un zéro à côté du DROP après du trafic réel comme une erreur de correspondance.

Le trafic transféré reste hors de ce schéma. Un conteneur ou une VM derrière un pont n'a aucun socket local possédé par vpnapp, donc la correspondance de propriétaire ne se déclenche jamais et ces paquets suivent la politique FORWARD à la place. Si cette charge doit être couverte, testez-la séparément et écrivez une politique liée au pont ou à l'adresse du conteneur. Le schéma tient quand la table, l'exception d'interface, le résolveur et les règles v6 pointent tous dans la même direction ; omettez l'un d'eux et le compte fuit ou perd la connectivité.

Articles similaires