Labo virtuel avec User Mode Linux

Vincent Bernat

Pour monter un réseau virtuel de test, il existe un certain nombre d’alternatives intéressantes telles que GNS3, Netkit, Marionnet ou VNUML. Certains de ces outils, notamment GNS3, permettent en plus d’ajouter à votre labo des équipements propriétaires tels qu’un Cisco 7200.

Tous ces outils permettent de mettre en place simplement et rapidement un réseau virtuel. Jetez-y un coup d’œil ! Si vous souhaitez mettre en place pour des TP d’enseignement un environnement virtuel, l’un d’eux conviendra forcément. Toutefois, aucun d’eux ne répondait parfaitement à mes besoins. Je voulais notamment pouvoir garder toute la configuration du labo dans un seul répertoire. Je ne voulais pas non plus maintenir des images disque, notamment à cause de l’espace disque important qu’elles peuvent occuper. La possibilité d’inclure des routeurs Cisco avec Dynamips/Dynagen m’est aussi très importante.

J’ai donc entrepris de monter un lab à partir de rien avec l’aide de User Mode Linux. Il ne s’agit pas d’une solution clé en main mais vraiment d’une solution à adapter à ses besoins. Encore une fois, il est sans doute préférable de capitaliser sur l’une des solutions présentées ci-dessus.

Je vais vous expliquer pas à pas comment cette solution est construite. Si vous êtes pressé, le résultat est disponible dans un dépôt GitHub.

User Mode Linux#

User Mode Linux (ou UML) est un noyau Linux. Celui-ci ne tourne pas sur un processeur physique tel que celui de votre machine mais en tant que simple processus, au même titre qu’un navigateur web. Avec Debian, un tel noyau est disponible dans le paquet user-mode-linux qui fournit une commande linux. Il est tout à fait inutile (voire dangereux dans notre cas) de la lancer en tant que root !

$ linux
Core dump limits :
    soft - 0
    hard - NONE
Checking that ptrace can change system call numbers...OK
Checking syscall emulation patch for ptrace...OK
Checking advanced syscall emulation patch for ptrace...OK
Checking for tmpfs mount on /dev/shm...nothing mounted on /dev/shm
Checking PROT_EXEC mmap in /tmp/user/500/...OK
Checking for the skas3 patch in the host:
  - /proc/mm...not found: No such file or directory
  - PTRACE_FAULTINFO...not found
  - PTRACE_LDT...not found
UML running in SKAS0 mode
Adding 5632000 bytes to physical memory to account for exec-shield gap
Initializing cgroup subsys cpuset
Linux version 2.6.32 (2.6.32) (root@tito) (gcc version 4.4.5 (Debian 4.4.5-10) ) #2 Thu Jan 27 12:49:46 UTC 2011
[…]
console [mc-1] enabled
Couldn't stat "root_fs" : err = 2
Failed to initialize ubd device 0 :Couldn't determine size of device's file
registered taskstats version 1
VFS: Cannot open root device "98:0" or unknown-block(98,0)
Please append a correct "root=" boot option; here are the available partitions:
Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(98,0)

Le noyau finit sur un panic car on ne lui a pas fourni d’image disque sur laquelle démarrer. Nous allons voir comment nous passer de cette image disque.

Configuration de base#

Il est en effet possible d’utiliser votre propre système comme image disque. Toutefois, on ne va pas laisser le noyau démarrer normalement, mais on va lui indiquer d’utiliser /bin/sh comme init. Ainsi, une fois le noyau démarré, on obtient un simple shell :

$ linux init=/bin/sh rootfstype=hostfs
[…]
Linux version 2.6.32 (2.6.32) (root@tito) (gcc version 4.4.5 (Debian 4.4.5-10) ) #2 Thu Jan 27 12:49:46 UTC 2011
[…]
VFS: Mounted root (hostfs filesystem) readonly on device 0:12.
IRQ 3/console-write: IRQF_DISABLED is not guaranteed on shared IRQs
IRQ 2/console: IRQF_DISABLED is not guaranteed on shared IRQs
IRQ 10/winch: IRQF_DISABLED is not guaranteed on shared IRQs
/bin/sh: can't access tty; job control turned off
# uname -a
Linux (none) 2.6.32 #2 Thu Jan 27 12:49:46 UTC 2011 x86_64 GNU/Linux
# echo $$
1

Il faut passer quelques commandes pour normaliser un peu l’environnement pour les programmes qui seront lancés par la suite :

# hostname -b R1
# export TERM=xterm
# export PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/local/sbin:/usr/sbin
# mount -t proc proc /proc
# mount -t sysfs sysfs /sys
# mount -t tmpfs tmpfs /var/run -o rw,nosuid,nodev
# mount -t tmpfs tmpfs /var/log -o rw,nosuid,nodev
# mount -o bind /usr/lib/uml/modules /lib/modules
# mount -t hostfs hostfs /home/bernat/mylab -o /home/bernat/mylab

On monte ainsi les pseudo systèmes de fichier /proc et /sys et on place des tmpfs pour un certain nombre de répertoires. Cela devrait permettre aux démons de démarrer normalement sur ce système.

La ligne à propos de /lib/modules permet de placer les modules dans un endroit que modprobe saura trouver. On monte de plus le /home via hostfs, en lecture/écriture. Le reste du système reste en lecture seule.

Les droits ont une importance toute particulière sur ce système. En effet, on se trouve root à l’intérieur de l’UML mais celle-ci tourne en tant que processus utilisateur sur votre système. Ainsi, si elle tente de modifier un fichier, les droits classiques s’appliquent. Par exemple :

# mount -o remount,rw /
# touch /tmp/test1
# touch /etc/test1
touch: cannot touch `/etc/test1': Permission denied

C’est un garde-fou important pour éviter de détruire votre système lors de vos tests.

Il est très intéressant de configurer ainsi son système car il est alors très simple de partager des fichiers de configuration entre le système hôte et l’UML. Pas besoin de les copier dans un sens ou dans l’autre. Ils sont accessibles et modifiables des deux côtés !

Console & gestion des tâches#

Il y a un inconvénient majeur à l’environnement que nous venons de mettre en place. Peut-être le connaissez-vous déjà si vous avez un jour démarré votre système en init=/bin/sh : nous ne disposons pas de « job control ». Il s’agit du mécanisme qui permet d’interrompre ou de mettre en tâche de fond un processus. Par exemple, on peut normalement stopper un programme avec ^C. Pas possible ici :

# cat
^C^C^C^C

La seule solution est de tuer le processus linux sur votre machine.

Ce mécanisme est un grand mystère pour moi, Je ne sais pas exactement ce qui l’active. Il se trouver que getty est capable de l’activer. On peut donc utiliser cette commande :

# exec getty -n -l /bin/bash 38400 /dev/tty0

Et voilà, problème résolu. On en a profité pour utiliser /bin/bash car /bin/sh est beaucoup trop minimaliste.

Écrire sur la partition racine#

Retournons à notre partition racine. Elle est en lecture seule mais nous avons aménagé ce qu’il faut pour que la plupart des démons fonctionnent sans problème. Par exmple, on peut démarrer Nginx, un serveur web assez populaire :

# mkdir /var/log/nginx
# /etc/init.d/nginx start
Starting nginx: nginx.

Toutefois, il va utiliser la configuration présente dans /etc/nginx et il n’est pas raisonnable d’aller modifier cette configuration pour chaque labo. On peut lui préciser un autre fichier de configuration :

# /etc/init.d/nginx stop
# nginx -c /home/bernat/mylab/nginx.conf

On garde ainsi les fichiers de configuration importants de notre labo dans le répertoire de celui-ci. Toutefois, il n’est alors plus possible d’utiliser le script de démarrage de Nginx. Sans compter que certains démons n’acceptent pas de recevoir un fichier de configuration en argument.

C’est un problème assez courant sur les Live CD et cela a été résolu en utilisant un système de fichiers capable de fusionner plusieurs systèmes de fichiers en un seul. L’un de ces systèmes de fichiers est AUFS. Il faut installer le paquet aufs-tools pour continuer. On recommence du début pour cet exemple :

$ linux init=/bin/sh rootfstype=hostfs
[…]
# mount -n -t proc proc /proc
# mount -n -t sysfs sysfs /sys
# mount -o bind /usr/lib/uml/modules /lib/modules
# mount -n -t tmpfs tmpfs /tmp -o rw,nosuid,nodev
# mkdir /tmp/ro /tmp/rw /tmp/aufs
# mount -n -t hostfs hostfs /tmp/ro -o /,ro
# mount -n -t aufs aufs /tmp/aufs -o noatime,dirs=/tmp/rw:/tmp/ro=ro
# exec chroot /tmp/aufs /bin/bash

Il aurait été assez sympa de pouvoir fusionner la racine avec un répertoire de votre /home : cela permettrait de garder les modifications dans le répertoire dédié à votre lab. Toutefois, AUFS ne semble pas bien s’etendre avec hostfs et le kernel émet des panics intempestifs.

Mise à jour (05.2011)

Il y a d’autres défauts avec cette configuration. Pour une raison qui m’échappe, si votre partition /usr est séparée de la partition racine sur votre système, il ne sera pas possible d’y modifier des fichiers. L’option noxino d’AUFS permet de contourner ce problème. Un autre soucis que vous pouvez rencontrer vient du fait que AUFS ne remarque pas la plupart des changements effectués directement sur votre système à moins d’utiliser l’option udba=inotify. Cependant, cette option est incompatible avec noxino. Il faut donc choisir ce qui convient le mieux à votre utilisation. À noter que vous pouvez monter avec hostfs d’autres parties de votre /home pour contourner ces limitations (par exemple, le répertoire contenant le code source d’un logiciel que vous voulez tester).

Reprenons notre exemple avec Nginx et disons que nous avons placé la configuration dans /home/bernat/lab/nginx. Nous remplaçons simplement le répertoire /etc/nginx par un lien symbolique vers ce nouveau répertoire :

# rm -rf /etc/nginx
# ln -s /home/bernat/mylab/nginx /etc/nginx

L’effacement de /etc/nginx n’a lieu en réalité que sur le tmpfs que nous avons mis en place pour recueillir les modifications effectuées sur la racine. En contre-partie, cette modification est perdue dès l’arrêt du lab. Il faut donc l’intégrer au script de mise en place du lab.

Réseau#

Maintenant que nous savons comme démarrer une machine UML, nous allons tenter d’ajouter quelques câbles. UML supporte quelques interfaces réseau virtuelles et notamment les interfaces TAP et les switchs VDE.

TAP#

Une interface TAP est une interface virtuelle qui peut être utilisée par un processus utilisateur pour injecter des trames Ethernet. Tout ce que le noyau envoie dans cette interface est reçu par l’application qui y écoute et vice-versa. Le noyau considère cette interface comme une simple interface Ethernet. Il est donc possible d’y faire des tcpdump ou de la bridger. L’inconvénient est qu’il faut être root pour les créer, mais il n’est pas nécessaire de lancer les applications qui vont les utiliser en tant que root.

J’ai une fonction shell que j’utilise quand je veux créer une telle interface :

__add_to_bridge() {
    # Optionally, add it to given bridge
    [ -z "$2" ] || {
        [ -f /sys/class/net/$2/brforward ] || {
            sudo brctl addbr $2
            sudo brctl stp $2 off
            sudo ip link set $2 up
        }
        [ -f /sys/class/net/$2/brif/$1 ] || {
            # We need to check if it is in another bridge
            bridge=$(echo /sys/class/net/*/brif/$1 2> /dev/null | \
                sed 's+/sys/class/net/\([^/]*\)/.*+\1+') 2> /dev/null
            [ -n "$bridge" ] && \
                sudo brctl delif $bridge $1
            sudo brctl addif $2 $1
        }
    }
}
tap() {
    sudo tunctl -b -u $(whoami) -t $1 > /dev/null
    sudo ip link set up dev $1
    __add_to_bridge $1 $2
}

Cette fonction va créer l’interface et éventuellement la placer dans un bridge. Voici comment créer deux interfaces et les lier entre elle par un câble virtuel :

$ tap tap-R1 br-R1R2
$ tap tap-R2 br-R1R2

Il est ensuite très simple de démarrer deux UML qui vont communiquer entre elles. Pour la première :

$ linux init=/bin/sh rootfstype=hostfs eth0=tuntap,tap-R1
[…]
# ip link set up dev eth0
# ip addr add 192.168.0.1/24 dev eth0

Et pour la seconde :

$ linux init=/bin/sh rootfstype=hostfs eth0=tuntap,tap-R2
[…]
# ip link set up dev eth0
# ip addr add 192.168.0.2/24 dev eth0
# ping 192.168.0.1
PING 192.168.0.1 (192.168.0.1) 56(84) bytes of data.
Warning: time of day goes back (-13832us), taking countermeasures.
64 bytes from 192.168.0.1: icmp_req=20 ttl=64 time=0.633 ms
64 bytes from 192.168.0.1: icmp_req=21 ttl=64 time=0.157 ms

Attention, si vous avez un firewall sur votre machine, celui-ci intercepte les paquets qui traversent le bridge. Attention de vérifier qu’il les laissera passer.

VDE#

Un switch VDE est une émulation logicielle assez basique d’un switch classique. C’est un composant qui tourne entièrement en espace utilisateur et qui ne nécessite pas les droits root. Il faut installer le paquet vde2 pour en profiter. Il est alors possible de lancer le switch avec la commande vde_switch. On obtient une console en pressant la touche Entrée. Il est possible d’effectuer quelques opérations de configuration, mais on ne va pas s’y attarder.

Reprenons notre exemple précédent et essayons de faire communiquer deux UML à travers ce switch. Première UML :

$ linux init=/bin/sh rootfstype=hostfs eth0=vde
[…]
# ip link set up dev eth0
# ip addr add 192.168.0.1/24 dev eth0

Deuxième UML :

$ linux init=/bin/sh rootfstype=hostfs eth0=vde
[…]
# ip link set up dev eth0
# ip addr add 192.168.0.2/24 dev eth0
# ping 192.168.0.1
PING 192.168.0.1 (192.168.0.1) 56(84) bytes of data.
64 bytes from 192.168.0.1: icmp_req=1 ttl=64 time=2.93 ms
Warning: time of day goes back (-8101us), taking countermeasures.
64 bytes from 192.168.0.1: icmp_req=2 ttl=64 time=0.503 ms

Le labo#

Maintenant que l’on a passé en revue les grandes briques nécessaires pour montrer notre labo, nous devons coller tout ça ensemble dans un script. Tout est disponible sur GitHub. Jetez un coup d’œil et forkez si cela vous tente.

Dans notre labo, nous allons mettre en place un VPN redondé entre deux sites distant. Chaque site utilise OSPF pour le routage interne. BGP sera utilisé pour échanger les routes entre les deux sites. Nous n’allons utiliser que des switch VDE pour la partie réseau.

Labo VPN redondés
VPN redondés entre deux sites

Pour voir comment j’ai implémenté ce lab, récupérez les sources de celui-ci :

$ git clone https://github.com/vincentbernat/network-lab.git
$ cd network-lab/lab-redundant-vpn

Vous obtenez un script setup qui permet de démarrer le lab. Les répertoires au nom des machines contiennent les fichiers de configuration pour Quagga (un démon de routage) et racoon (un démon IKE pour IPsec). Il faut donc que vous installiez les paquets quagga et racoon sur votre mahcine.

Configuration des UML#

Regardez d’abord la fin du script. Il se présente ainsi :

case $$ in
    1)
        # Inside UML. Three states:
        []
        ;;
    *)
        TMP=$(mktemp -d)
        trap "rm -rf $TMP" EXIT
        check_dependencies
        setup_screen
        # Setup switches
        setup_switch site1
        setup_switch site101
        setup_switch internet
        # Start VM
        start_vm R1 eth0=vde,$TMP/switch-site1.sock
        start_vm R2 eth0=vde,$TMP/switch-site101.sock
        start_vm V1 eth0=vde,$TMP/switch-site1.sock eth1=vde,$TMP/switch-internet.sock
        start_vm V2 eth0=vde,$TMP/switch-site1.sock eth1=vde,$TMP/switch-internet.sock
        start_vm V3 eth0=vde,$TMP/switch-site101.sock eth1=vde,$TMP/switch-internet.sock
        start_vm V4 eth0=vde,$TMP/switch-site101.sock eth1=vde,$TMP/switch-internet.sock
        start_vm I1 eth0=vde,$TMP/switch-internet.sock
        display_help
        cleanup
        screen -X quit
        ;;
esac

Il vérifie quel est son PID ($$). S’il a le PID 1, cela signifie qu’il a été invoqué en tant que init pour une UML. Sinon, c’est qu’il est lancé par un utilisateur. Dans ce cas, il effectue quelques vérifications et configure screen. En effet, tout le lab va tourner dans screen et il ne va pas y avoir plein de fenêtres partout. Ensuite, le script configure les 3 switchs nécessaires pour le lab et démarrer les machines UML.

Pour chaque machine à lancer, on précise quelles sont les interfaces réseau que l’on veut mettre en place. Pour R1 et R2, on utilisera une interface dummy0 pour le réseau interne. On ne demande donc qu’une seule interface. Une machine, I1, est destinée à émuler « Internet ».

Chaque UML va utiliser comme init ce même script. Ce dernier va effectuer la configuration propre à chaque UML à l’aide d’une instruction case :

echo "[+] Setup UML"
sysctl -w net.ipv4.ip_forward=1
case ${uts} in
    R1)
        modprobe dummy
        ip link set up dev dummy0
        ip addr add 192.168.15.1/24 dev dummy0
        ip addr add 192.168.1.10/24 dev eth0
        setup_quagga
        ;;
    R2)
        modprobe dummy
        ip link set up dev dummy0
        ip addr add 192.168.115.1/24 dev dummy0
        ip addr add 192.168.101.10/24 dev eth0
        setup_quagga
        ;;
    V1)
        ip addr add 192.168.1.11/24 dev eth0
        ip addr add 1.1.2.1/24 dev eth1
        ip route add default via 1.1.2.10
        setup_quagga
        setup_racoon 1.1.2.1 1.1.1.1 192.168.0.0/19-192.168.100.0/19
        ;;
    V2)
        ip addr add 192.168.1.12/24 dev eth0
        ip addr add 1.1.2.2/24 dev eth1
        ip route add default via 1.1.2.10
        setup_quagga
        setup_racoon 1.1.2.2 1.1.1.2 192.168.0.0/19-192.168.100.0/19
        ;;
    V3)
        ip addr add 192.168.101.13/24 dev eth0
        ip addr add 1.1.1.1/24 dev eth1
        ip route add default via 1.1.1.10
        setup_quagga
        setup_racoon 1.1.1.1 1.1.2.1 192.168.100.0/19-192.168.0.0/19
        ;;
    V4)
        ip addr add 192.168.101.14/24 dev eth0
        ip addr add 1.1.1.2/24 dev eth1
        ip route add default via 1.1.1.10
        setup_quagga
        setup_racoon 1.1.1.2 1.1.2.2 192.168.100.0/19-192.168.0.0/19
        ;;
    I1)
        ip addr add 1.1.1.10/24 dev eth0
        ip addr add 1.1.2.10/24 dev eth0
        ;;
esac

Enfin, il un shell est lancé quand tout est fini pour permettre d’invoquer de manière interactive des commandes pour examiner ou interagir avec chaque machine.

Tests#

Une fois démarré, il faut attendre la mise en place de toutes les adjacences. Le timing est un peu faussé sur des UML et il faut attendre une bonne minute. On peut ensuite examiner la table de routage de R1 (il faut taper vtysh pour obtenir la console de Quagga) :

vtysh@R1# show ip route
Codes: K - kernel route, C - connected, S - static, R - RIP, O - OSPF,
       I - ISIS, B - BGP, > - selected route, * - FIB route

C>* 127.0.0.0/8 is directly connected, lo
O   192.168.1.0/24 [110/10] is directly connected, eth0, 00:25:42
C>* 192.168.1.0/24 is directly connected, eth0
O   192.168.15.0/24 [110/10] is directly connected, dummy0, 00:25:42
C>* 192.168.15.0/24 is directly connected, dummy0
O>* 192.168.115.0/24 [110/20] via 192.168.1.11, eth0, 00:02:24
  *                           via 192.168.1.12, eth0, 00:02:24

Pour contacter R2, il a appris une route multipath : il peut soit passer par V1, soit par V2, comme prévu. Regardons la table de routage de V3 :

vtysh@V3# show ip route
Codes: K - kernel route, C - connected, S - static, R - RIP, O - OSPF,
       I - ISIS, B - BGP, > - selected route, * - FIB route

K>* 0.0.0.0/0 via 1.1.1.10, eth1
C>* 1.1.1.0/24 is directly connected, eth1
C>* 127.0.0.0/8 is directly connected, lo
O   192.168.15.0/24 [110/20] via 192.168.101.14, eth0, 00:04:26
B>* 192.168.15.0/24 [20/20] via 192.168.1.11 (recursive via 1.1.1.10), 00:07:16
O   192.168.101.0/24 [110/10] is directly connected, eth0, 00:07:30
C>* 192.168.101.0/24 is directly connected, eth0
O>* 192.168.115.0/24 [110/20] via 192.168.101.10, eth0, 00:07:18

Pour contacter R1, V3 connaît deux routes. L’une via BGP à travers le VPN. C’est cette route qui sera utilisée. L’autre route passe par son collègue, V4, et ne sera utilisée que si la première route est perdue (si le VPN est indisponible par exemple).

Pinguons R2 depuis R1 :

vtysh@R1# ping -I 192.168.15.1 192.168.115.1
PING 192.168.115.1 (192.168.115.1) from 192.168.15.1 : 56(84) bytes of data.
Warning: time of day goes back (-459129us), taking countermeasures.
64 bytes from 192.168.115.1: icmp_req=1 ttl=62 time=1.10 ms

Si nous cassons le lien entre V1 et « Internet » (avec ip link set down eth1 sur V1), la route multipath sur R1 se transforme en une route simple vers V2. De plus, V3 a perdu sa route BGP vers V1 et utilise donc sa route OSPF vers V4 :

vtysh@V3# show ip route
Codes: K - kernel route, C - connected, S - static, R - RIP, O - OSPF,
       I - ISIS, B - BGP, > - selected route, * - FIB route

K>* 0.0.0.0/0 via 1.1.1.10, eth1
C>* 1.1.1.0/24 is directly connected, eth1
C>* 127.0.0.0/8 is directly connected, lo
O>* 192.168.15.0/24 [110/20] via 192.168.101.14, eth0, 00:10:13
O   192.168.101.0/24 [110/10] is directly connected, eth0, 00:13:17
C>* 192.168.101.0/24 is directly connected, eth0
O>* 192.168.115.0/24 [110/20] via 192.168.101.10, eth0, 00:13:05

Il est possible très simplement d’améliorer la tolérance aux pannes de ce réseau en plaçant une interconnexion entre V1 et V2 et entre V3 et V4. Il est ainsi possible de supporter plusieurs pannes sur notre réseau.

Conclusion#

Il est assez simple de monter d’autres labs en adaptant légèrement le script. Notons toutefois que son bon fonctionnement dépend de la configuration de votre machine. Cela autorise toutefois de contenir le lab en entier dans un simple répertoire qui peut tout à fait transiter par mail ou être publié sur GitHub.

Il est possible de le modifier pour y inclure des équipements Cisco en utilisant Dynagen pour, par exemple, expérimenter avec VRF ou MPLS.