Aller au contenu

Lab : un réseau dans une machine

200 Pratiquer ⏱ 3 h reseaulinuxipv4tcpnftables

Prérequis

Testé avec iproute2 6.1.0 nftables 1.0.9 ubuntu 24.04 , vérifié le 5 octobre 2026

Ce lab rassemble le cours Le modèle TCP/IP. Vous construisez, dans une seule machine Linux, trois « machines » isolées reliées par des câbles virtuels : le poste de l'utilisatrice, un routeur qui joue le rôle de la box et de la passerelle, et le serveur de Signalements. Puis un script vérifie que l'adressage, le routage, la traduction d'adresses et le filtrage ont les propriétés attendues. Il valide le niveau 200 de la compétence « Modèle TCP/IP, adressage et routage ».

Comptez une demi-journée. Toutes les commandes nécessaires sont dans les leçons ou dans les pages de manuel ip-netns(8), ip-link(8), veth(4) et nft(8) ; essayez d'écrire chacune avant de chercher.

Ce qu'il faut

  • Une machine virtuelle Ubuntu 24.04 jetable, avec sudo. Le lab crée des espaces de noms réseau et des règles de pare-feu : pas sur votre poste.
  • Les paquets iproute2, nftables, curl, tcpdump et python3, tous présents ou installables par apt.

Un espace de noms réseau (network namespace) donne à un groupe de processus sa propre pile réseau : interfaces, adresses, table de routage, règles de pare-feu, réglages sysctl. C'est le mécanisme qui isole le réseau des conteneurs (voir Namespace). Une paire veth est un câble virtuel : ce qui entre par un bout sort par l'autre, et chaque bout peut être placé dans un espace de noms différent (voir veth).

Ce qu'il faut atteindre

    flowchart LR
  subgraph C["client (le poste)"]
    c1["veth-c<br/>192.168.1.10/24"]
  end
  subgraph R["routeur (box + passerelle)"]
    r1["veth-rc<br/>192.168.1.1/24"]
    r2["veth-rs<br/>172.16.20.1/22"]
  end
  subgraph S["serveur (sig-app-1)"]
    s1["veth-s<br/>172.16.20.11/22<br/>:8000 et :8001"]
  end
  c1 --- r1
  r2 --- s1
  

L'application

Le serveur fait tourner une version minimale de Signalements, qui répond à GET /sante et renvoie l'adresse source qu'elle voit : c'est elle qui montrera l'effet de la traduction d'adresses. Enregistrez-la sous /root/sante.py :

#!/usr/bin/env python3
"""Répond à GET /sante avec l'adresse vue du client. Usage : sante.py ADRESSE PORT"""
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer


class Gestionnaire(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path.startswith("/sante"):
            code, corps = 200, {"etat": "ok", "client": self.client_address[0]}
        else:
            code, corps = 404, {"erreur": "introuvable"}
        if self.path.startswith("/sante/gros"):
            corps["remplissage"] = "x" * 200_000
        donnees = json.dumps(corps).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(donnees)))
        self.end_headers()
        self.wfile.write(donnees)


HTTPServer((sys.argv[1], int(sys.argv[2])), Gestionnaire).serve_forever()

/sante/gros renvoie environ 200 Ko : une réponse qui ne tient pas dans un seul paquet, utile pour l'étape sur la MTU.

Les exigences

ExigenceLeçon
Trois espaces de noms client, routeur et serveur, reliés par deux paires veth, avec les adresses du schéma2, 3
Le client et le serveur ont une route par défaut vers le routeur ; le routeur fait suivre les paquets (ip_forward)5
L'application écoute sur 172.16.20.11:8000 et 172.16.20.11:8001, pas sur 0.0.0.09
Le client obtient 200 sur http://172.16.20.11:8000/sante1, 10
Le routeur fait la traduction d'adresses source vers le réseau du serveur : l'application voit 172.16.20.1, pas 192.168.1.107
Une connexion du client vers le port 9999 du serveur est refusée immédiatement ; une connexion vers le port 8001 expire, parce que le routeur jette ces paquets sans répondre10, 12
Le lien entre le routeur et le serveur a une MTU de 1400 ; /sante/gros se télécharge quand même6

Étape 1 : câbler

Créez les trois espaces de noms (ip netns add), les deux paires veth (ip link add ... type veth peer name ...), placez chaque bout dans son espace (ip link set ... netns ...), donnez les adresses, montez les interfaces et la boucle locale lo de chaque espace. Vérifiez chaque lien par un ping vers le voisin direct, puis regardez la table des voisins (ip -n client neigh) : l'entrée ARP du routeur y est apparue (leçon 2).

Étape 2 : router

Ajoutez les routes par défaut, puis essayez ping du client vers le serveur avant d'activer le suivi des paquets sur le routeur : observez avec tcpdump -ni veth-rc dans l'espace routeur que la requête arrive et ne repart pas. Activez net.ipv4.ip_forward dans l'espace routeur (ip netns exec routeur sysctl -w ...) et recommencez. Testez ip -n serveur route get 192.168.1.10 (leçon 5).

Étape 3 : servir

Lancez deux exemplaires de l'application dans l'espace serveur, sur les ports 8000 et 8001, en arrière-plan (ip netns exec serveur python3 /root/sante.py 172.16.20.11 8000 &). Depuis le client, curl sur /sante : la réponse indique 192.168.1.10. Le serveur voit l'adresse réelle du poste, ce qui n'arrive jamais sur Internet, où le poste est derrière une box.

Étape 4 : traduire et filtrer

Dans l'espace routeur, écrivez un jeu de règles nftables avec deux tables :

  • une table ip nat avec une chaîne postrouting (type nat hook postrouting priority srcnat) qui applique masquerade aux paquets qui sortent par veth-rs ;
  • une table inet filtre avec une chaîne forward (type filter hook forward priority filter; policy accept;) qui jette (drop, pas reject) les paquets TCP à destination du port 8001.

Rechargez, puis refaites le curl : l'application voit maintenant 172.16.20.1. Comparez ensuite curl --connect-timeout 3 vers les ports 9999 et 8001, et lisez le code de sortie de curl (echo $?) et le message : « Connection refused » d'un côté, délai dépassé de l'autre. Capturez les deux cas avec tcpdump dans l'espace serveur : un RST dans le premier, rien dans le second (leçons 10 et 12).

Étape 5 : la MTU

D'abord, voyez la découverte du MTU du chemin à l'œuvre. Réglez la MTU des deux bouts du lien client-routeur à 1400 (ip -n client link set veth-c mtu 1400, et de même pour veth-rc), puis envoyez depuis le serveur un paquet de 1500 octets qui interdit la fragmentation : ip netns exec serveur ping -c 1 -M do -s 1472 192.168.1.10. Le routeur ne peut pas le faire passer, et répond par un message ICMP « fragmentation nécessaire » qui annonce la MTU du lien suivant ; lisez-le dans la sortie de ping, puis recommencez : le noyau du serveur a retenu la MTU du chemin (ip -n serveur route get 192.168.1.10 l'affiche). Exercice facultatif et instructif : ajoutez sur le routeur une règle qui jette ces messages ICMP, et observez ce qui arrive à un gros transfert du serveur vers le client (leçon 6). Retirez ensuite cette règle et remettez ce lien à 1500.

Ensuite, l'état final attendu par la vérification : réglez la MTU des deux bouts du lien routeur-serveur à 1400 (veth-rs et veth-s), et téléchargez /sante/gros depuis le client. Ça fonctionne sans aucun message ICMP : le serveur, dont l'interface a une MTU de 1400, découpe lui-même ses segments en conséquence, et le client, qui annonce une MSS de 1460, reçoit simplement des segments plus petits.

Vérifier

Enregistrez le script sous verifier-lab.sh et lancez-le avec sudo bash verifier-lab.sh. Il ne modifie rien : il lit l'état des espaces de noms et fait quelques requêtes depuis le client.

#!/usr/bin/env bash
# Vérifie les propriétés attendues du lab « un réseau dans une machine ».
# Usage : sudo bash verifier-lab.sh
# Chaque contrôle affiche OK ou ÉCHEC ; le code de sortie est le nombre d'échecs.
set -uo pipefail
export LC_ALL=C.UTF-8
echecs=0

controle() {   # controle "libellé" commande...
  local libelle=$1; shift
  if "$@" > /dev/null 2>&1; then
    echo "OK     $libelle"
  else
    echo "ÉCHEC  $libelle"
    echecs=$((echecs + 1))
  fi
}

adresse() {   # adresse espace interface adresse/préfixe
  ip -n "$1" -4 -o addr show dev "$2" | grep -q " inet $3 "
}

dans() {   # dans espace commande...
  local espace=$1; shift
  ip netns exec "$espace" "$@"
}

[ "$(id -u)" -eq 0 ] || { echo "Lancez ce script avec sudo." >&2; exit 1; }

for e in client routeur serveur; do
  controle "l'espace de noms $e existe" test -e "/run/netns/$e"
done

controle "client : 192.168.1.10/24 sur veth-c" adresse client veth-c 192.168.1.10/24
controle "routeur : 192.168.1.1/24 sur veth-rc" adresse routeur veth-rc 192.168.1.1/24
controle "routeur : 172.16.20.1/22 sur veth-rs" adresse routeur veth-rs 172.16.20.1/22
controle "serveur : 172.16.20.11/22 sur veth-s" adresse serveur veth-s 172.16.20.11/22

controle "client : route par défaut vers 192.168.1.1" \
  bash -c 'ip -n client route show default | grep -q "via 192.168.1.1 "'
controle "serveur : route par défaut vers 172.16.20.1" \
  bash -c 'ip -n serveur route show default | grep -q "via 172.16.20.1 "'
controle "routeur : suivi des paquets activé" \
  bash -c '[ "$(ip netns exec routeur sysctl -n net.ipv4.ip_forward)" = 1 ]'

controle "l'application écoute sur 172.16.20.11:8000 et :8001 seulement" \
  bash -c '[ "$(ip netns exec serveur ss -Hltn "( sport = :8000 or sport = :8001 )" | awk "{print \$4}" | sort | tr "\n" " ")" = "172.16.20.11:8000 172.16.20.11:8001 " ]'

controle "le client atteint le serveur (ping)" dans client ping -c 1 -W 2 172.16.20.11
reponse=$(dans client curl -fsS --max-time 5 http://172.16.20.11:8000/sante 2>/dev/null)
controle "GET /sante répond 200 depuis le client" test -n "$reponse"
controle "l'application voit l'adresse traduite 172.16.20.1" \
  bash -c 'grep -q "\"client\": \"172.16.20.1\"" <<< "$1"' _ "$reponse"

controle "le port 9999 est refusé (curl : code 7)" \
  bash -c 'ip netns exec client curl -s -o /dev/null --connect-timeout 3 http://172.16.20.11:9999/; [ $? -eq 7 ]'
controle "le port 8001 expire (curl : code 28)" \
  bash -c 'ip netns exec client curl -s -o /dev/null --connect-timeout 3 http://172.16.20.11:8001/; [ $? -eq 28 ]'

controle "le lien routeur-serveur a une MTU de 1400" \
  bash -c 'ip -n serveur -o link show veth-s | grep -q " mtu 1400 "'
controle "/sante/gros se télécharge en entier depuis le client" \
  bash -c '[ "$(ip netns exec client curl -fsS --max-time 10 http://172.16.20.11:8000/sante/gros | wc -c)" -gt 200000 ]'

echo
echo "$echecs échec(s)"
exit "$echecs"

Quelques contrôles demandent une explication :

  • /run/netns/<nom> est le fichier que crée ip netns add : il garde l'espace de noms en vie même quand aucun processus n'y tourne.
  • Les codes de sortie de curl distinguent les deux pannes : 7 signifie que la connexion a échoué (ici, un RST en réponse au SYN), 28 que le délai est dépassé (aucune réponse). La page de manuel de curl liste ces codes dans sa section EXIT CODES.
  • L'adresse vue par l'application est la seule preuve directe de la traduction d'adresses : ip netns exec routeur nft list ruleset montre la règle, mais pas qu'elle s'applique.

Démonter

ip netns delete client routeur serveur supprime les trois espaces de noms ; les interfaces veth disparaissent avec eux, et les processus Python lancés dans l'espace serveur restent à arrêter (pkill -f sante.py). Supprimez ensuite la machine virtuelle si elle ne sert plus.

Plan du cours