Configuration d'un Réseau Privé Virtuel (VPN)

Créer son propre Réseau Virtuel Privé (VPN) est assez facile sur des plateformes qui sont livrées avec un pilote tun: cela permet de traiter le flux des paquets réseaux au niveau de l'espace utilisateur. Bien que cela soit considérablement plus facile que de faire de la programmation réseau dans le noyau, il reste cependant quelques détails à prendre en compte. Cet article vous guidera à travers mes trouvailles.

IFF_TUN Versus IFF_TAP

Le Pilote tun est un périphérique deux-en-un:

Cet article parle du code à écrire pour le périphérique Ethernet.Si vous choisissez le périphérique IP, alors vous génèrerez 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.

Activation du pilote

Premièrement, on doit s'assurer que le pilote tun est actif.Sur mon système Debian, je dois tout simplement 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

La Configuration

Pour des besoins de démonstration, on mettra en place un réseau virtuel de deux hôtes. Une fois qu'on a mis la main sur les paquets Ethernet, nous utiliserons l'encapsulation UDP pour les faire transiter d'une interface virtuelle sur l'hôte A vers une interface virtuelle sur l'hôte B et vice-versa. Le socket UDP sera utilisé en mode non-connecté; cela a l'avantage de nous permettre d'utiliser le 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, complique la réception du chemin MTU (plus de détails dessus dans les prochaines lignes).

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

Le mécanisme de découverte

En pratique, nous avons besoin d'un mécanisme pour faire correspondre les adresses IP virtuelles aux adresses IP réelles. Il nous appartient de bidouiller une méthode de découverte pour résoudre ce problème de correspondance; cependant comme cela n'est pas en rapport direct avec notre sujet du jour, ni nécessaire pour les besoins de notre petite démonstration décrite ici, nous allons tricher et laisser le programme de tunnelling établir les correspondances par des lignes de commande:

Mise en place de l'interface

La première chose à faire est de créer l'interface Ethernet virtuelle (tap). Cela se fait avec 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, le MTU, etc*/

Ici, l'indicateur IFF_NO_PI requiert qu'on manipule de trames brutes. Si on ne fait pas cela, les trames se verront ajouter un en-tête de 4 octets.

Interface setup: the IP address

L'interface virtuelle doit être identifiée par une adresse IP. On la définira avec un appel de la fonction ioctl():

/* 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 le MTU.*/
        return sock; 
    }

Le PMTU (Path Maximum Transmission Unit)

La seul élément que nous avons à configurer est le MTU (Maximum Transmit Unit) de l'interface. Pour notre interface pseudo-Ethernet, le MTU est la charge la plus importante que les trames Ethernet peuvent transporter. On définira le PMTU en fonction du MTU.

Pour faire simple, on dira que le PMTU est le paquet le plus large qui peut traverser le chemin allant d'un hôte à sa destination sans subir de fragmentation.

Le PMTU est un paramètre important à prendre en compte pour que tout se passe bien. Considérez ceci: en (ré)injectant vos trames dans le noyau, elles se verront attribuer des en-têtes (IP, UDP et Ethernet) supplémentaires. Si la taille de la trame que vous avez envoyée au noyau est trop proche du PMTU, la trame finale qui sera envoyée à partir de l'interface réelle pourrait être plus grande que le PMTU. Au pire des cas, une telle trame sera perdue quelque part en route. Au meilleur des cas, la trame sera fragmentée en deux et la première partie sera traitée à 100%, ce qui ne sera pas le cas pour la seconde partie.

Pour éviter cela, on doit découvrir la valeur du PMTU et s'assurer que la nouvelle trame Ethernet sera correctement dimensionnée pour le PMTU. Ensuite, on soustraira du PMTU la taille des nouvelles en-têtes et on donnera cette valeur au MTU de l'interface virtuelle.

La tâche est facile sous Linux, pour un socket TCP: on doit juste s'assurer que les mécanismes de découverte du PMTU par le noyau sont configurés, et c'est tout.

Par contre pour les sockets UDP, nous les utilisateurs, avons la responsabilité de nous assurer que les datagrammes UDP sont de taille correcte. Si le socket UDP est connecté à l'hôte correspondant, un simple appel appel de la fonction getsockopt()avec l'indicateur IP_MTU positionné, nous donnera le PMTU

En ce qui concerne les sockets non-connectés, on doit analyser le PMTU. Premièrement, le socket doit être configuré de façon à ce que les datagrammes ne soient pas fragmentés (positionner l'indicateur DF); ensuite, on doit configurer de façon à être averti des erreurs ICMP que cela pourrait générer. Si un hôte ne peut pas gérer la taille du datagramme sans le fragmenter, alors il devra nous en informer (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 du PMTU.*/

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

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

Pour finir, on farfouille parmi les erreurs jusqu'à trouver le bon PMTU. Si on a une erreur de PMTU, on ajuste la taille du datagramme 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 du 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");
        /*Nothing for now, return.*/
    }

    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écouvrte du PMTU: pas d'info jusque là. Retour, mais continue à analyser.*/
    }


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

    return mtu; /*Mais continue à analyser jusqu'à ce que l'hôte distant soit atteint!*/

Une dernière chose: le PMTU est tenu de changer dans le temps. On devra donc le tester de temps en temps et ensuite configurer le MTU de l'interface virtuelle en fonction des résultats du test. Si on veut éviter toute cette gymnastique, on peut configurer le MTU de façon sécurisée mais moins optimale : choisir la valeur la plus petit entre 576 et le MTU de l'interface physique (moins l'en-tête que nous avons mentionné, bien entendu.)

Configuration de l'interface: le MTU

Après avoir finalement obtenu cette valeur magique qu'est le PMTU, nous pouvons correctement configurer le MTU de notre interface:

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

Encapsulation UDP

Nous avons maintenant une interface virtuelle bien configurée et qui fonctionne. Tout ce que nous avons à faire est de relayer les trames dans les deux directions. Premièrement, il faut ouvrir un socket UDP non-connecté (Je vous épargne les détails), et ensuite:

  1. Lire les paquets du fichier descriptif du tap et les envoyer à l'adresse IP physique distante de notre hôte correspondant; ceci 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); 
  1. lire les datagrammes du socket UDP et les envoyer à travers le descripteur de fichier 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 avec le Makefile; tout ce dont on parlé ci-dessus est aussi téléchargeable sous la forme d'une archive tar.

P comme Privé

Comme on a le contrôle total du trafic de notre réseau virtuel, on peut le crypter dans l'espace utilisateur. Pour les besoins de cette démonstration, nous allons construire un VPN complet, que nous allons crypter avec IPSEC (remarque: IPSEC a aussi une fonctionnalité intégrée de tunnelling).

Sous Debian, il faut juste installer le paquetage ipsec-tools et utiliser ces fichiers pour des manipulations manuelles:

Pour l'hôte A:

Pour l'hôte B:

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

Le VPN en marche

C'est parti! Faisons un ping de 100 octets sur l'interface virtuelle de l'autre hôte:

Regardons le trafic avec tcpdump sur l'interface virtuelle::

Sur l'interface physique:

Tous les chiffres en gras sont des charges utiles. Quand il sort de l'interface virtuelle, le datagramme crypté 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 est 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 le bloc de fin de la trame Ethernet. Nous avons donc rajouté 46 octets par datagramme.

Ressources