Gestion de réseau VPN (réseau privé virtuel)

Gazette Linux n°149 — Avril 2008

Aurelian Melinte

Kamal Sawal Toure

Adaptation française  

Joëlle Cornavin

Relecture de la version française  

Article paru dans le n°149 de la Gazette Linux d'avril 2008.

Cet article est publié selon les termes de la Open Publication License. La Linux Gazette n'est ni produite, ni sponsorisée, ni avalisée par notre hébergeur principal, SSC, Inc.


Table des matières

1. IFF_TUN par rapport à IFF_TAP
2. Activation du pilote
3. La configuration
4. Le mécanisme de découverte
5. Mise en place de l'interface
6. Configuration de l'interface : l'adresse IP
7. La PMTU (Path Maximum Transmission Unit)
8. Configuration de l'interface : la MTU
9. Encapsulation UDP
10. P comme Privé
11. Le VPN à l'œuvre
12. Ressources

Créer son propre VPN est assez facile sur les plates-formes comportant un pilote tun : celui-ci permet de traiter le trafic des paquets réseau dans l'espace utilisateur. Bien que ce soit considérablement plus facile que de faire de la programmation réseau dans l'espace du noyau, il reste cependant quelques détails à comprendre. Cet article devrait vous guider à travers mes découvertes.

1. IFF_TUN par rapport à IFF_TAP

Le pilote tun est un périphérique « deux en un » :

  • Un périphérique point à point (IFF_TUN). Le périphérique TUN est chargé du traitement des paquets IP.

  • Un périphérique de type Ethernet (IFF_TAP). Le périphérique TAP traite les trames Ethernet.

Cet article aborde le code écrit à propos du périphérique Ethernet. Si vous choisissez le pilote IP, alors vous générerez environ 18 octets par paquet traité ; le trafic est certes moins important (l'en-tête et la queue du paquet Ethernet), mais vous aurez à coder un peu plus pour mettre en place votre réseau.

2. Activation du pilote

Premièrement, il faut s'assurer que le pilote tun est actif. Sur mon système Debian, il me suffit de le charger :

# /sbin/modprobe tun
# /sbin/lsmod | grep tun
tun                    10208  0

# /bin/ls -l /dev/net/tun
crw-rw-rw- 1 root root 10, 200 2008-02-10 11:30 /dev/net/tun

3. La configuration

À des fins de démonstration, on construira un réseau virtuel composé de deux hôtes. Après avoir mis la main sur les paquets Ethernet, on fera appel à l'encapsulation UDP pour les transmettre depuis une interface virtuelle située sur l'hôte A vers une interface virtuelle présente sur l'hôte B et vice versa. La socket UDP sera utilisée en mode non connecté, ce qui présente l'avantage d'employer la même socket pour envoyer et recevoir des paquets depuis n'importe quel autre hôte de notre réseau virtuel. Toutefois, la nature non connectée de notre socket UDP soulève quelques difficultés dans l'obtention de la MTU du chemin (vous trouverez plus de détails à ce propos ci-dessous).

Chaque hôte présent dans notre réseau virtuel exécutera une instance du programme de démonstration. Pour l'illustrer, le trafic provenant d'une application (telnet dans le cas présent) sur l'hôte A vers son application correspondante (inetd/telnetd) sur l'hôte B, prendra le chemin suivant :

4. Le mécanisme de découverte

En pratique, nous avons besoin d'un mécanisme pour faire correspondre les adresses IP virtuelles avec les adresses IP réelles. Il nous appartient de mettre au point une méthode de découverte permettant de résoudre ce problème de mise en correspondance — cependant, comme ce n'est pas en rapport direct avec notre sujet ni nécessaire pour les besoins de notre petite démonstration décrite ici, nous allons tricher et laisser le programme de tunnelisation établir les correspondances à l'aide de paramètres en ligne de commande :

Host A# ./udptun
Usage: ./udptun local-tun-ip remote-physical-ip
Host A# ./udptun 172.16.0.1   192.168.2.103
Host B# ./udptun 172.16.0.111 192.168.2.113

5. Mise en place de l'interface

La première tâche à consiste à créer une interface Ethernet virtuelle (tap). Il suffit d'un simple appel à la fonction open() :


struct ifreq ifr_tun; 
    int fd; 
    
    if ((fd = open("/dev/net/tun", O_RDWR)) < 0) {
        /*Erreur de traitement, retour.*/;
    }

    memset( &ifr_tun, 0, sizeof(ifr_tun) );
    ifr_tun.ifr_flags = IFF_TAP | IFF_NO_PI;
    if ((ioctl(fd, TUNSETIFF, (void *)&ifr_tun)) < 0) {
        /*Erreur de traitement, retour.*/;
    }
    
    /*Configurer l'interface : définir l'adresse IP, la MTU, etc.*/
    

Ici, le drapeau IFF_NO_PI demande qu'on manipule des trames brutes. Si elles ne sont pas définies, les trames se verront ajouter un en-tête de 4 octets.

6. Configuration de l'interface : l'adresse IP

L'interface virtuelle doit être identifiée par une adresse IP. Un appel à la fonction ioctl() s'en chargera :


/* Définir l'IP de cette terminaison du tunnel */
    int set_ip(struct ifreq *ifr_tun, unsigned long ip4)
    {
        struct sockaddr_in addr;
        int sock = -1; 

        sock = socket(AF_INET, SOCK_DGRAM, 0);

        if (sock < 0) {
            /*Erreur de traitement, retour*/
        }

        memset(&addr, 0, sizeof(addr));
        addr.sin_addr.s_addr = ip; /*network byte order*/
        addr.sin_family = AF_INET;
        memcpy(&ifr_tun->ifr_addr, &addr, sizeof(struct sockaddr));

        if (ioctl(sock, SIOCSIFADDR, ifr_tun) < 0) {
            /*Erreur de traitement, retour*/
        }

        /*Sera utilisé plus tard pour définir la MTU.*/
        return sock; 
    }

7. La PMTU (Path Maximum Transmission Unit)

Le seul autre paramètre restant à configurer est la MTU de l'interface. Pour notre interface pseudo Ethernet, la MTU est la charge utile la plus importante que les trames Ethernet peuvent transporter. On définira la PMTU en fonction de la MTU.

Pour simplifier, la PMTU est la plus grosse taille de paquet qui peut traverser le chemin depuis votre hôte vers son hôte de destination sans subir de fragmentation.

La PMTU est un paramètre important à prendre en compte pour que tout se passe bien. Considérez ce point : lorsque vous (ré)injecterez vos trames dans le noyau, elles obtiendront un nouvel ensemble d'en-têtes (IP, UDP et Ethernet). Ainsi, si la taille de la trame envoyée au noyau est trop proche de la PMTU, la trame finale envoyée à partir de l'interface réelle pourrait être plus importante que la PMTU. Au pire des cas, une telle trame sera perdue quelque part « en cours de route ». Au mieux, la trame sera scindée en deux fragments et générera une surcharge de traitement de 100 % ainsi que du trafic supplémentaire.

Pour éviter ce problème, il faut découvrir la valeur de la PMTU et s'assurer que la nouvelle trame Ethernet aura une taille appropriée pour la PMTU. Donc, on soustraira de la PMTU la charge que représente le nouvel ensemble d'en-têtes et on donnera cette valeur à la MTU de l'interface virtuelle.

La tâche est facile sous Linux, pour une socket TCP : il suffit de s'assurer que les mécanismes de découverte de la PMTU par le noyau sont configurés, et c'est tout.

Pour les sockets UDP en revanche, nous les utilisateurs, avons la responsabilité de garantir que les datagrammes UDP sont d'une taille correcte. Si la socket UDP est connectée à votre hôte correspondant, un simple appel à la fonction getsockopt() avec le drapeau IP_MTU positionné, nous donnera la PMTU.

En ce qui concerne les sockets non connectées, nous devons examiner la PMTU. Premièrement, la socket doit être configurée de façon à ce que les datagrammes ne soient pas fragmentés (positionner le drapeau DF) ; ensuite, on souhaitera être averti de toute erreur d'ICMP qui pourrait être générée. Si un hôte ne peut pas gérer la taille du datagramme sans le fragmenter, alors il devra nous en informer en conséquence (enfin, on l'espère) :


int sock;
    int on;

    sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0) {
        /*Erreur de traitement, retour*/;
    }

    on = IP_PMTUDISC_DO;
    if (setsockopt(sock, SOL_IP, IP_MTU_DISCOVER, &on, sizeof(on))) {
        /*Erreur de traitement, retour*/;
    }
    
    on = 1;
    if (setsockopt(sock, SOL_IP, IP_RECVERR, &on, sizeof(on))) {
        /*Erreur de traitement, retour*/;
    }
    
    /*Utiliser sock pour la découverte de la PMTU.*/

Ensuite, on envoie des datagrammes vérifiés de diverses tailles :

int wrote = rsendto(sock, buf, len, 0, 
                    (struct sockaddr*)target, 
                    sizeof(struct sockaddr_in));

Pour finir, on cherche parmi les erreurs jusqu'à trouver la bonne PMTU. Si on a une erreur de PMTU, on ajuste la taille du datagramme d'autant et on recommence à envoyer les datagrammes jusqu'à ce que la destination soit atteinte :


char sndbuf[VPN_MAX_MTU] = {0};
    struct iovec  iov;
    struct msghdr msg;
    struct cmsghdr *cmsg = NULL;
    struct sock_extended_err *err = NULL;
    struct sockaddr_in addr;
    int res; 
    int mtu;

    if (recv(sock, sndbuf, sizeof(sndbuf), MSG_DONTWAIT) > 0) {
        /* Réponse reçue. Fin de la découverte de la PMTU. Retour.*/
    }

    msg.msg_name = (unsigned char*)&addr;
    msg.msg_namelen = sizeof(addr);
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_flags = 0;
    msg.msg_control = cbuf;
    msg.msg_controllen = sizeof(cbuf);
    res = recvmsg(sock, &msg, MSG_ERRQUEUE);
    if (res < 0) {
        if (errno != EAGAIN)
            perror("recvmsg");
        /*Rien pour le moment, retour.*/
    }

    for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
        if (cmsg->cmsg_level == SOL_IP) {
            if (cmsg->cmsg_type == IP_RECVERR) {
                err = (struct sock_extended_err *) CMSG_DATA(cmsg);
            }
        }
    }
    if (err == NULL) {
        /*Découverte de la PMTU : pas d'info jusque là. Retour pour l'instant, mais continuer à vérifier.*/
    }


    mtu = 0; 
    switch (err->ee_errno) {
    ...
    case EMSGSIZE:
        debug("  EMSGSIZE pmtu %d\n", err->ee_info);
        mtu = err->ee_info; 
        break;
    ...
    } /*commutateur de fin*/

    return mtu; /*Mais continuer à vérifier jusqu'à ce que l'hôte distant soit atteint !*/

Une dernière remarque : la PMTU doit nécessairement changer dans le temps. On devra donc la tester à nouveau une fois un peu plus tard, puis définir la MTU de l'interface virtuelle en conséquence. Pour éviter toute cette gymnastique, on peut définir la MTU d'une façon « sécurisée » mais moins optimale : choisir la valeur la plus petite entre 576 et la MTU de l'interface physique (moins la surcharge susmentionnée, bien entendu).

8. Configuration de l'interface : la MTU

Enfin, après avoir obtenu cette valeur magique qu'est la PMTU, nous pouvons définir la MTU de notre interface virtuelle correctement :


struct ifreq *ifr_tun;
    ...
    ifr_tun->ifr_mtu = mtu; 
    if (ioctl(sock, SIOCSIFMTU, ifr_tun) < 0)  {
        /*Erreur de traitement*/
    }

9. Encapsulation UDP

Nous avons maintenant une interface virtuelle bien configurée et qui fonctionne. Il ne reste plus qu'à relayer les trames dans les deux directions. Tout d'abord, il faut ouvrir une socket UDP non connectée (je vous épargne les détails), puis :

  1. Lire les paquets du descripteur de fichier du périphérique tap et les envoyer à l'adresse IP physique distante de notre hôte correspondant, ce qui enverra les paquets dans une seule direction.

    
    char buf[VPN_MAX_MTU] = {0}; 
        struct sockaddr_in cliaddr = {0}; 
        int recvlen = -1; 
        socklen_t clilen = sizeof(cliaddr); 
        
        recvlen = read(_tun_fd, buf, sizeof(buf));
        if (recvlen > 0)
            sendto(_udp_fd, buf, recvlen, 0, (struct sockaddr*)&cliaddr, clilen); 
    	      
    

    Avertissement

    Utiliser la fonction read() depuis le descripteur de fichier du périphérique tap va provoquer un sérieux blocage. Cela veut dire que l'appel à la fonction read() ne sera pas interrompu dans l'éventualité où on ferme le descripteur de fichier sous-jacent. Il faut donc utiliser les fonctions poll()/select() sur ce descripteur de fichier avant d'exécuter la fonction read() sur ce dernier si on veut pouvoir terminer ce fil d'exécution (thread) proprement.

  2. Lire les datagrammes provenant de la socket UDP et les envoyer par le biais du descripteur de fichier du périphérique tap : les données circuleront maintenant dans la direction opposée.

    
    recvlen = recvfrom(_udp_fd, buf, sizeof(buf), 0, 
                           (struct sockaddr*)&cliaddr, &clilen);
        if (recvlen > 0)
            write(_tun_fd, buf, recvlen); 
    	      
    

    Notez qu'en pratique, si on a plus de deux hôtes dans son réseau virtuel, on doit regarder le contenu des trames pour vérifier les adresses IP source et destination avant de décider où envoyer la trame.

    On peut télécharger les sources des fichiers udptun.c, ttools.c, ttools.h et pathmtu.c ainsi que le Makefile directement  ; tout ce qui est évoqué ci-dessus est également disponible sous la forme d'une archive tarball.

10. P comme Privé

Comme on a le contrôle total du trafic du réseau virtuel, on peut le chiffrer dans l'espace utilisateur. Pour les besoins de cette démonstration, nous allons construire un VPN complet et chiffrer le trafic avec IPSEC.

Note

IPSEC aussi est doté de la fonctionnalité intégrée de tunnelisation.

Sous Debian, installez simplement le paquetage ipsec-tools et utilisez ces fichiers pour effectuer les manipulations manuelles :

Pour l'hôte A :


## Vider le SAD et SPD
    flush;
    spdflush;

    # A & B
    add 172.16.0.1    172.16.0.111  ah 15700 -A hmac-md5  "123456789.123456"; 
    add 172.16.0.111  172.16.0.1    ah 24500 -A hmac-md5  "123456789.123456"; 

    add 172.16.0.1    172.16.0.111  esp 15701 -E 3des-cbc "123456789.123456789.1234"; 
    add 172.16.0.111  172.16.0.1    esp 24501 -E 3des-cbc "123456789.123456789.1234"; 

    # A
    spdadd 172.16.0.1  172.16.0.111 any -P out ipsec
            esp/transport//require
            ah/transport//require;
    spdadd 172.16.0.111  172.16.0.1 any -P in ipsec
            esp/transport//require
            ah/transport//require;

Pour l'hôte B :


## Vider le SAD et SPD
    flush;
    spdflush;

    # A & B
    add 172.16.0.1    172.16.0.111  ah 15700 -A hmac-md5 "123456789.123456"; 
    add 172.16.0.111  172.16.0.1    ah 24500 -A hmac-md5 "123456789.123456"; 

    add 172.16.0.1    172.16.0.111  esp 15701 -E 3des-cbc 
           "123456789.123456789.1234"; 
    add 172.16.0.111  172.16.0.1    esp 24501 -E 3des-cbc 
           "123456789.123456789.1234"; 

    #dump ah;
    #dump esp; 

    # B
    spdadd 172.16.0.111  172.16.0.1 any -P out ipsec
            esp/transport//require
            ah/transport//require;
    spdadd 172.16.0.1  172.16.0.111 any -P in ipsec
            esp/transport//require
            ah/transport//require;

Remarquez comme le mécanisme de chiffrement tout entier est lié aux adresses virtuelles, vous isolant ainsi des réseaux physiques sur lesquels vos hôtes se trouvent. Vous pouvez télécharger le fichier ipsec-tools directement.

11. Le VPN à l'œuvre

Le spectacle commence ! Faisons un ping sur l'interface virtuelle de l'autre hôte avec une charge utile de 100 octets :

Host A$ ping -s 100 172.16.0.111 

Et observons le trafic avec tcpdump sur l'interface virtuelle :


#tcpdump -i tap0
    ...
    15:43:27.739218 IP 172.16.0.1 > 172.16.0.111: AH(spi=0x00003d54,seq=0x1d):
           ESP(spi=0x00003d55,seq=0x1d), length 128
    15:43:27.740673 IP 172.16.0.111 > 172.16.0.1: AH(spi=0x00005fb4,seq=0x1d):
           ESP(spi=0x00005fb5,seq=0x1d), length 128
    15:43:28.738741 IP 172.16.0.1 > 172.16.0.111: AH(spi=0x00003d54,seq=0x1e): 
           ESP(spi=0x00003d55,seq=0x1e), length 128
    15:43:28.740170 IP 172.16.0.111 > 172.16.0.1: AH(spi=0x00005fb4,seq=0x1e):
           ESP(spi=0x00005fb5,seq=0x1e), length 128
    15:43:39.494298 IP 172.16.0.1 > 172.16.0.111: AH(spi=0x00003d54,seq=0x1f):
           ESP(spi=0x00003d55,seq=0x1f), length 64
    15:43:39.496818 IP 172.16.0.111 > 172.16.0.1: AH(spi=0x00005fb4,seq=0x1f):
           ESP(spi=0x00005fb5,seq=0x1f), length 40

Sur l'interface physique :


 # tcpdump -i eth2
    ... 
    15:45:46.878156 IP 192.168.40.128.11223 > 192.168.40.129.11223: UDP, 
            length 186
    15:45:46.879021 IP 192.168.40.129.11223 > 192.168.40.128.11223: UDP, 
            length 186
    15:45:47.879479 IP 192.168.40.128.11223 > 192.168.40.129.11223: UDP, 
            length 186
    15:45:47.887054 IP 192.168.40.129.11223 > 192.168.40.128.11223: UDP, 
            length 186
    15:45:48.880268 IP 192.168.40.128.11223 > 192.168.40.129.11223: UDP, 
            length 186
    15:45:48.882738 IP 192.168.40.129.11223 > 192.168.40.128.11223: UDP, 
            length 186

Tous les chiffres comme 739218 ou 878156 sont des charges utiles. Quand il sort de l'interface virtuelle, la taille du datagramme chiffré est de 186 octets : 14 octets pour l'en-tête Ethernet, 20 pour l'en-tête IP, un en-tête AH de 24 octets et un ESP pour les 128 octets restants.

Quand il sort de l'interface physique, le datagramme a une taille de 232 octets : 14 octets pour l'en-tête Ethernet, 20 octets pour l'en-tête IP, 8 pour l'en-tête UDP, 186 octets de charge physique et 4 octets pour la queue de la trame Ethernet. Ainsi, nous avons rajouté une surcharge de 46 octets par datagramme.

12. Ressources

Aurelian Melinte est un développeur de logiciels professionnel. Il a parfois programmé sous Windows, parfois sous Linux et même sur les systèmes embarqués. Il a découvert Linux en 1998 et en apprécie l'utilisation depuis lors. Il travaille actuellement sur une Debian.

Adaptation française de la Gazette Linux

L'adaptation française de ce document a été réalisée dans le cadre du Projet de traduction de la Gazette Linux.

Vous pourrez lire d'autres articles traduits et en apprendre plus sur ce projet en visitant notre site : http://www.traduc.org/Gazette_Linux.

Si vous souhaitez apporter votre contribution, n'hésitez pas à nous rejoindre, nous serons heureux de vous accueillir.