Split Tunneling avec OpenVPN

Objectif

Une solution souple pour faire du split tunneling sous Linux consiste à lancer un client OpenVPN dans un network namespace dédié, de telle sorte que :

  • la totalité du trafic des commandes lancées depuis ce namespace passe automatiquement et obligatoirement par le VPN
  • sans que le reste de la configuration et du trafic réseau ne soit modifié en aucune manière

Ce qui suit est basé sur cette note de Sebastian Thorarensen (merci !) et testé sur une xubuntu 18.04. Le VPN est fourni par NordVPN. Pour l'essentiel, les commandes devraient fonctionner sans grosse modification avec l'ensemble des solutions OpenVPN, et des distributions Linux basées sur (cette horreur de) systemd.

Installer OpenVPN

Par défaut, OpenVPN n'est pas installé. Le plus simple est de le récupérer pré-configuré avec apt

sudo apt -y install openvpn

Par la suite on se contente de modifier (le moins possible) la configuration par défaut.

Récupérérer les configurations NordVPN

Voir le tuto dédié à Linux sur le site de NordVPN.

sudo wget https://downloads.nordcdn.com/configs/archives/servers/ovpn.zip

Choisir une des configurations proposées, par exemple fr172.nordvpn.com.tcp.ovpn et la copier vers /etc/openvpn/client/nordvpn.conf. Remplacer la directive d'authentification

-auth-user-pass

par celle-ci

auth-user-pass login.txt

et créer au même niveau le fichier login.txt : deux lignes, utilisateur et mot de passe (mode 600, root:root)

Vérifier que le VPN fonctionne

Le VPN doit fonctionner tout seul, indépendamment du namespace. Le client récupère depuis le serveur des routes par défaut qui captent tout le trafic réseau. On le lance, on vérifie que tout fonctionne (routes mises à jour et IP vue depuis Internet différente), et on l'arrête.

État initial

> curl ipinfo.io
{
  "ip": "193.52.24.31",
  "hostname": "31.0-127.24.52.193.in-addr.arpa",
  "city": "Issy-les-moulineaux",
  "region": "Ile-de-France",
  "country": "FR",
  "loc": "48.8210,2.2772",
  "postal": "92130",
  "org": "AS2200 Renater"
}

> ip route
default via 10.0.2.2 dev enp0s3 proto dhcp metric 100
10.0.2.0/24 dev enp0s3 proto kernel scope link src 10.0.2.15 metric 100
169.254.0.0/16 dev enp0s8 scope link metric 1000
192.168.99.0/24 dev enp0s8 proto kernel scope link src 192.168.99.102 metric 101

Lancer le VPN

> systemctl start openvpn-client@nordvpn.service
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===
Authentification requise pour démarrer « openvpn-client@nordvpn.service ».
Authenticating as: Julien,,, (julien)
==== AUTHENTICATION COMPLETE ===
> systemctl status openvpn-client@nordvpn.service
● openvpn-client@nordvpn.service - OpenVPN tunnel for nordvpn
   Loaded: loaded (/lib/systemd/system/openvpn-client@.service; disabled; vendor preset: enabled)
   Active: active (running) since Tue 2018-07-24 15:32:35 CEST; 2s ago
     Docs: man:openvpn(8)
           https://community.openvpn.net/openvpn/wiki/Openvpn24ManPage
           https://community.openvpn.net/openvpn/wiki/HOWTO
 Main PID: 5431 (openvpn)
   Status: "Pre-connection initialization successful"
    Tasks: 1 (limit: 4663)
   CGroup: /system.slice/system-openvpn\x2dclient.slice/openvpn-client@nordvpn.service
           └─5431 /usr/sbin/openvpn --suppress-timestamps --nobind --config nordvpn.conf

État après lancement du VPN

curl ipinfo.io
{
  "ip": "185.93.2.42",
  "city": "Paris",
  "region": "Île-de-France",
  "country": "FR",
  "loc": "48.9333,2.3667",
  "postal": "93200",
  "org": "AS60068 Datacamp Limited"
}
> ip route
0.0.0.0/1 via 10.7.7.1 dev tun0
default via 10.0.2.2 dev enp0s3 proto dhcp metric 100
10.0.2.0/24 dev enp0s3 proto kernel scope link src 10.0.2.15 metric 100
10.7.7.0/24 dev tun0 proto kernel scope link src 10.7.7.246
128.0.0.0/1 via 10.7.7.1 dev tun0
169.254.0.0/16 dev enp0s8 scope link metric 1000
185.93.2.42 via 10.0.2.2 dev enp0s3
192.168.99.0/24 dev enp0s8 proto kernel scope link src 192.168.99.102 metric 101

L'IP vue depuis Internet a changé, elle est passée de 193.52.24.31 à 185.93.2.42, l'interface du VPN, tun0, est là, et de nouvelles routes passent par elle. Tout est bien !

Arrêt du VPN

> systemctl stop openvpn-client@nordvpn.service
==== AUTHENTICATING FOR org.freedesktop.systemd1.manage-units ===
Authentification requise pour arrêter « openvpn-client@nordvpn.service ».
Authenticating as: Julien,,, (julien)
==== AUTHENTICATION COMPLETE ===

Créer un fichier de service dédié

La configuration par défaut est globale pour la machine. Or on souhaite lancer OpenVPN dans un namespace. En se basant sur cette configuration, on va créer le service dédié ns-openvpn-client@.service.

sudo cp /lib/systemd/system/openvpn-client@.service /lib/systemd/system/ns-openvpn-client@.service

Dans ce nouveau fichier, il faut modifier la ligne ExecStart qui lance le client OpenVPN. Sur ma machine, les options suivantes sont présentes

ExecStart=/usr/sbin/openvpn --suppress-timestamps --nobind --config %i.conf

On ajoute les appels au script helper.sh qui monte le tunnel dans le namespace. La configuration des routes et des interfaces étant faite par ce script, on ajoute également --ifconfig-noexec et --route-noexec, ainsi que l'option --script-security 2 sans laquelle le client OpenVPN se montre réticent à lancer un script externe.

ExecStart=/usr/sbin/openvpn --suppress-timestamps --nobind \
	--ifconfig-noexec --route-noexec --script-security 2 \
	--up helper.sh --route-up helper.sh --down helper.sh \
	--config %i.conf

Il faut également modifier la ligne CapabilityBoundingSet afin d'éviter l'erreur mount --make-shared /var/run/netns failed: Operation not permitted lors de la création du namespace. Deux possibilités : commenter la ligne ou ajouter la capacité CAP_SYS_ADMIN.

CapabilityBoundingSet=CAP_IPC_LOCK CAP_NET_ADMIN CAP_NET_RAW CAP_SETGID CAP_SETUID CAP_SYS_CHROOT CAP_DAC_OVERRIDE CAP_SYS_ADMIN

Copier le fichier helper.sh dans /etc/openvpn/client, et demander à systemd de recharger ses services pour prendre en compte ns-openvpn-client@.service

> systemctl daemon-reload
==== AUTHENTICATING FOR org.freedesktop.systemd1.reload-daemon ===
Authentification requise pour recharger l'état de systemd
Authenticating as: Julien,,, (julien)
==== AUTHENTICATION COMPLETE ===

À propos du script helper.sh

Le script helper.sh permet de gérer le namespace si on l'appelle à la main, et de configurer le tunnel lorsqu'il est appelé par le client OpenVPN. Il faut le copier lui aussi dans le répertoire /etc/openvpn/client. Il crée à cet endroit les fichiers de logs up.log, route-up.log et down.log. Comme ils sont écrasés à chaque appel, ils ne grossissent pas. Ils permettent de voir comment l'interface du tunnel est configurée. Lancé en mode manuel, le script accepte les commandes create-ns, show-ns, remove-ns et clean-log.

Configurer le namespace

Pour une raison qui reste à déterminer (discutée ci-après), si le namespace est créé directement depuis le script helper.sh lors de son premier appel par le client OpenVPN (comportement par défaut), le nouveau namespace n'est pas fonctionnel. Pour contourner ce problème, on crée préalablement le namespace à la main, une fois pour toute, à l'aide du script helper.sh. Par la suite, si le script détecte que le namespace existe lors du up de l'interface, il ne le détruira pas lors du down.

> sudo ./helper.sh create-ns
Create namespace
cat /etc/netns/NordVPN/resolv.conf
nameserver 103.86.96.100
nameserver 103.86.99.100
ip netns add NordVPN
ip netns exec NordVPN ip link set dev lo up

Les DNS utilisés par le script proviennent du support NordVPN. On vérifie que le namespace a été créé correctement, et que l'interface de loopback est up.

> sudo ./helper.sh show-ns
NordVPN
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever

Par la suite si on veut supprimer le namespace, on utilise

> sudo ./helper.sh remove-ns
ip netns delete NordVPN
rm -Rf /etc/netns/NordVPN

Quelques alias pratiques

On utilise systemd et les commandes start / status / stop pour lancer et arrêter le service, voir enable / status / disable si on préfère qu'il soit lancé automatiquement au démarrage du système. Les alias suivants aident un peu si on choisit de ne pas lancer le service au démarrage.

alias vpn-start='sudo systemctl start ns-openvpn-client@nordvpn.service '
alias vpn-status='sudo systemctl status ns-openvpn-client@nordvpn.service '
alias vpn-stop='sudo systemctl stop ns-openvpn-client@nordvpn.service '

Les alias vpn-cmd et vpn-bash permettent respectivement de lancer une commande et un Bash dans le namespace NordVPN

alias vpn-cmd='sudo ip netns exec NordVPN '
alias vpn-bash='sudo ip netns exec NordVPN sudo -u julien bash '

Lancer le vpn dans le namespace

On lance le vpn et on vérifie que les commandes exécutées dans le namespace passent par lui

> vpn-start

> vpn-cmd curl ipinfo.io
{
  "ip": "185.93.2.42",
  "hostname": "unn-185-93-2-42.datapacket.com",
  "city": "Paris",
  "region": "Île-de-France",
  "country": "FR",
  "loc": "48.9333,2.3667",
  "postal": "93200",
  "org": "AS60068 Datacamp Limited"
}

Et si l'idée vous prend de vouloir ajouter à votre prompt le namespace courant, la commande dont vous avez besoin est ip netns identify

> vpn-bash

> ip netns identify
NordVPN

Bug Invalid argument

Idéalement, le namespace devrait être créé depuis le script : automatique et transparent. Hélas, comme dit précédemment, le namespace qu'on récupère ainsi n'est pas fonctionnel. Cette commande illustre le problème

> sudo ip netns exec ns1 echo toto
setting the network namespace "ns1" failed: Invalid argument

En relançant la commande avec sudo strace ip netns exec [...] on constate cette erreur, à creuser…

openat(AT_FDCWD, "/var/run/netns/ns1", O_RDONLY|O_CLOEXEC) = 5
setns(5, CLONE_NEWNET)                  = -1 EINVAL (Invalid argument)
write(2, "setting the network namespace \"n"..., 61setting the network namespace "ns1" failed: Invalid argument) = 61
close(5)                                = 0

La trace complète se trouve dans le fichier invalid.trace.