3 Les limites de TCP : concevoir un protocole
Objectifs de la séance
- Constater par l’expérience que TCP ne préserve pas les frontières entre messages.
- Comprendre pourquoi : TCP transporte un flux d’octets.
- Savoir découper correctement un flux : délimiteur ou longueur préfixée.
- Écrire un module
protocole.pyréutilisable, que nous emploierons jusqu’à la fin du cours.
3.1 Une expérience qui dérange
Prenons un serveur qui envoie trois messages d’affilée, puis raccroche :
serveur_trois.py
import socket
serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("127.0.0.1", 50007))
serveur.listen(1)
connexion, _ = serveur.accept()
connexion.sendall(b"PREMIER")
connexion.sendall(b"DEUXIEME")
connexion.sendall(b"TROISIEME")
connexion.close()Et un client qui lit trois fois :
client_trois.py
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1", 50007))
print("1er recv :", client.recv(1024))
print("2e recv :", client.recv(1024))
print("3e recv :", client.recv(1024))
client.close()Ce que l’on espère :
1er recv : b'PREMIER'
2e recv : b'DEUXIEME'
3e recv : b'TROISIEME'Ce que l’on obtient très souvent :
1er recv : b'PREMIERDEUXIEMETROISIEME'
2e recv : b''
3e recv : b''Les trois messages ont fusionné en un seul recv. Ce n’est pas un bug de Python, ni du réseau. C’est le fonctionnement normal de TCP.
Le pire, dans ce comportement, c’est qu’il est intermittent. Sur localhost avec de petits messages, la fusion est quasi systématique. Sur un vrai réseau, avec des messages plus gros, vous obtiendrez parfois le découpage attendu… et parfois non. C’est la recette parfaite pour un bug qui « marche sur ma machine » et échoue en production une fois sur mille.
3.2 Pourquoi : TCP est un flux d’octets
Le bon modèle mental
En séance 1, nous avons dit que TCP garantit que les octets arrivent dans l’ordre. Il faut prendre cette phrase au pied de la lettre.
TCP transporte un flux continu d’octets (byte stream), exactement comme un tuyau d’eau. Il ne transporte pas des messages. La notion même de « message » n’existe pas pour TCP : c’est une idée qui n’existe que dans votre tête et dans votre code.
Quand vous faites trois sendall, vous versez trois seaux d’eau dans le même tuyau. À la sortie, il n’y a plus trois seaux : il y a de l’eau. TCP a fait exactement ce qu’il promettait — les octets sont tous là, dans le bon ordre — mais les frontières que vous imaginiez n’ont jamais existé pour lui.
Ce qui se passe réellement
Deux mécanismes expliquent le phénomène.
La coalescence (les messages fusionnent). sendall ne transmet pas immédiatement sur le réseau : il copie les octets dans un tampon d’émission géré par le système. Le système décide ensuite quand émettre, et il a intérêt à regrouper : un paquet réseau porte environ 40 octets d’en-têtes, donc envoyer b"PREMIER" seul (7 octets utiles) serait un gaspillage. Un algorithme appelé Nagle regroupe volontairement les petites écritures.
La fragmentation (un message se coupe en deux). Le phénomène inverse existe aussi. Un paquet réseau ne peut dépasser une certaine taille (environ 1500 octets sur Ethernet). Si vous envoyez 10 000 octets d’un coup, ils seront découpés en plusieurs paquets, et le destinataire pourra très bien recevoir 1448 octets, puis 1448 autres, etc.
Les quatre cas possibles
Quand vous écrivez donnees = sock.recv(1024), quatre situations peuvent se produire :
| Résultat | Signification |
|---|---|
| Exactement un message | Le cas chanceux. Ne comptez jamais dessus. |
| Plusieurs messages collés | Coalescence. |
| Un morceau de message | Fragmentation : le reste arrivera plus tard. |
b"" (vide) |
L’autre côté a fermé la connexion. |
Deux règles absolues, sources de la majorité des bugs réseau :
recv(n)renvoie au plus n octets. Il peut en renvoyer 1, ou 3, ou n. Jamais plus.recv()qui renvoieb""signifie connexion fermée, et pas pas de données pour l’instant. Si vous ne testez pas ce cas dans une boucle, vous obtenez une boucle infinie qui consomme 100 % d’un cœur.
Une comparaison utile
| TCP | UDP | |
|---|---|---|
| Unité transportée | flux d’octets | datagramme (message) |
| Frontières préservées | non | oui |
| Livraison garantie | oui | non |
| Ordre garanti | oui | non |
C’est le paradoxe : UDP, le protocole « peu fiable », préserve les frontières de messages, alors que TCP, le protocole « fiable », ne les préserve pas. Chacun résout un problème différent.
3.3 La solution : le framing
Puisque TCP ne délimite pas les messages, c’est à nous de le faire. C’est ce qu’on appelle le framing (le « cadrage »). Il existe deux techniques, et il n’y en a pas d’autres.
Technique 1 : le délimiteur
On convient qu’un caractère particulier marque la fin d’un message. Presque toujours le retour à la ligne \n.
C’est exactement ce que font HTTP, SMTP (courriel), IRC ou Redis.
Python offre une facilité très pratique : makefile(), qui enveloppe le socket dans un objet ressemblant à un fichier.
Framing par délimiteur
# Cote serveur
flux = connexion.makefile("r", encoding="utf-8")
for ligne in flux: # une ligne = un message
commande = ligne.strip()
if commande == "QUIT":
break
connexion.sendall((traiter(commande) + "\n").encode())
# Cote client
client.sendall("PING\n".encode()) # ne pas oublier le \n
flux = client.makefile("r", encoding="utf-8")
reponse = flux.readline().strip()Avantages. Simple, lisible, on peut tester à la main avec telnet ou nc.
Inconvénient majeur. Que se passe-t-il si le message contient un retour à la ligne ? Il faut alors l’échapper, ce qui complique tout. Et cette technique convient mal aux données binaires (une image, un fichier compressé), qui contiennent forcément des octets \n par hasard.
Technique 2 : la longueur préfixée
On annonce d’abord combien d’octets vont suivre, puis on envoie les octets.
[ 4 octets : taille = 9 ][ 9 octets : TROISIEME ]
Le lecteur procède en deux temps : il lit exactement 4 octets pour connaître la taille n, puis il lit exactement n octets. Aucune ambiguïté, quel que soit le contenu du message.
Un détour nécessaire : lire des octets en hexadécimal
Dès qu’on manipule un en-tête binaire, on a besoin de regarder les octets. Et pour cela, tout le monde utilise l’hexadécimal.
Pourquoi la base 16. Un octet vaut de 0 à 255. Écrit en décimal, il occupe 1, 2 ou 3 chiffres : une suite d’octets devient illisible car on ne sait plus où s’arrête chacun. En base 16, un octet s’écrit toujours avec exactement deux chiffres, de 00 à FF. Une suite d’octets se lit alors deux caractères par deux caractères, sans ambiguïté.
Les seize chiffres. Après 9, on continue avec les lettres A à F :
| hexa | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| décimal | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| hexa | 8 | 9 | A | B | C | D | E | F |
|---|---|---|---|---|---|---|---|---|
| décimal | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
Convertir à la main. Chaque chiffre hexadécimal pèse une puissance de 16, comme chaque chiffre décimal pèse une puissance de 10 :
Dans l’autre sens, on divise par 16 : , donc 2A.
La notation 0x. Comme 11 peut désigner onze (décimal) ou dix-sept (hexadécimal), on préfixe par 0x tout nombre écrit en base 16. C’est une convention universelle, partagée par Python, C, Java et la quasi-totalité des spécifications de protocoles.
En Python :
0x2A # 42 : litteral hexadecimal, c'est un int
0xFF # 255
int("2A", 16) # 42 : texte hexa -> entier
int("0x2A", 16) # 42 : le prefixe est tolere
hex(42) # '0x2a' : entier -> texte hexa
f"{42:02X}" # '2A' : sur 2 chiffres, en majuscules
b"\x00\x00\x00\x0b".hex() # '0000000b'
bytes.fromhex("0000000b") # b'\x00\x00\x00\x0b'
int.from_bytes(b"\x00\x0b", "big") # 11
(11).to_bytes(2, "big") # b'\x00\x0b'Soit les quatre octets 00 00 00 0B. Le dernier vaut , les trois autres valent 0 : l’entier représenté est donc 11. Ce réflexe — traduire un en-tête hexadécimal en entier — vous servira à chaque fois que vous déboguerez un protocole binaire.
Encoder un entier sur 4 octets : struct
import struct
octets = struct.pack("!I", 9) # b'\x00\x00\x00\x09'
(valeur,) = struct.unpack("!I", octets) # 9Le format "!I" se lit ainsi :
!: ordre des octets réseau, dit gros-boutiste (big endian), le standard universel sur Internet ;I: entier non signé sur 4 octets (jusqu’à environ 4 milliards).
Les autres tailles d’entiers
I n’est qu’une lettre parmi d’autres. Chacune désigne une taille :
| Lettre | Taille | Valeurs possibles |
|---|---|---|
B |
1 octet | 0 à 255 |
H |
2 octets | 0 à 65 535 |
I |
4 octets | 0 à 4 294 967 295 |
Q |
8 octets | 0 à |
Les majuscules désignent des entiers non signés ; les minuscules correspondantes (b, h, i, q) sont signées et acceptent donc des valeurs négatives, sur la même taille.
On peut enchaîner plusieurs lettres dans un même format. Un en-tête de 2 octets de longueur suivi d’un code sur 1 octet s’écrit ainsi :
struct.pack("!HB", 6, 1) # b'\x00\x06\x01' : 3 octets
struct.calcsize("!HB") # 3
struct.unpack("!HB", b"\x00\x06\x01") # (6, 1)struct.calcsize évite de compter à la main
Plutôt que d’écrire TAILLE_ENTETE = 3 et d’espérer ne pas s’être trompé, écrivez TAILLE_ENTETE = struct.calcsize(FORMAT). Le jour où vous changez le format, la taille suit toute seule.
Le choix de la taille n’est pas anodin : il fixe la valeur maximale transportable. Un protocole dont la longueur tient sur H ne pourra jamais porter un message de plus de 65 535 octets — c’est une limite du protocole lui-même, pas un détail d’implémentation. Notre protocole.py utilise I et peut donc aller jusqu’à 4 Go, ce qui explique le garde-fou TAILLE_MAX.
Les processeurs ne rangent pas les octets d’un entier dans le même ordre. Pour le nombre 11, codé sur deux octets :
| Convention | Octets | Qui |
|---|---|---|
| gros-boutiste (big endian) | 00 0B |
les protocoles réseau |
| petit-boutiste (little endian) | 0B 00 |
les processeurs Intel et ARM |
Le gros-boutiste place d’abord l’octet de plus fort poids — comme nous écrivons les nombres, les centaines avant les unités. C’est celui qu’imposent tous les protocoles d’Internet, d’où son autre nom network byte order.
Sans le !, struct utilise l’ordre natif du processeur : un client Intel enverrait 0B 00 là où un serveur attendant du gros-boutiste lirait . Le ! élimine définitivement ce risque : mettez-le toujours.
La fonction qui manque à Python : recevoir_exactement
Comme recv(n) peut renvoyer moins de n octets, il faut boucler jusqu’à en avoir assez. Cette fonction est le cœur de tout code réseau correct :
def recevoir_exactement(sock, nb_octets):
"""Lit exactement nb_octets, ou leve une exception."""
morceaux = b""
while len(morceaux) < nb_octets:
morceau = sock.recv(nb_octets - len(morceaux))
if not morceau: # b"" : connexion fermee
raise ConnectionError("Connexion fermee prematurement")
morceaux += morceau
return morceaux3.4 Sérialiser : transporter autre chose que du texte
Le framing règle la question « où finit le message ? ». Reste la question « comment transporter un dictionnaire, une liste, des nombres ? ».
On ne peut envoyer que des octets. Transformer un objet Python en octets s’appelle sérialiser ; l’opération inverse, désérialiser.
Nous utiliserons JSON, un format texte lisible et compris par tous les langages :
import json
objet = {"commande": "MAP", "fichier": "part-003.txt", "id": 3}
texte = json.dumps(objet) # str : '{"commande": "MAP", ...}'
octets = texte.encode("utf-8") # bytes, pret a envoyer
# A l'arrivee
retour = json.loads(octets.decode("utf-8"))
print(retour["fichier"]) # part-003.txt| Format | Avantages | Inconvénients |
|---|---|---|
| JSON | lisible, universel, débogable | volumineux, lent |
pickle |
natif Python, tous les objets | faille de sécurité, Python seulement |
Binaire (struct) |
compact, rapide | rigide, illisible |
N’utilisez jamais pickle sur des données venant du réseau. Désérialiser un pickle malveillant permet d’exécuter du code arbitraire sur votre machine. C’est l’équivalent, côté données, du eval() que nous avons refusé en séance 1.
3.5 Le module protocole.py
Assemblons tout cela en un module que nous utiliserons jusqu’à la fin du cours. C’est le code le plus important de ce cours : sachez le réécrire de mémoire.
protocole.py
"""Envoi/reception de messages JSON avec longueur prefixee."""
import json
import struct
TAILLE_ENTETE = 4 # "!I" = 4 octets
TAILLE_MAX = 100 * 1024 * 1024 # garde-fou : 100 Mo
def envoyer_message(sock, objet):
"""Serialise l'objet et l'envoie precede de sa taille."""
charge = json.dumps(objet).encode("utf-8")
entete = struct.pack("!I", len(charge))
sock.sendall(entete + charge)
def recevoir_exactement(sock, nb_octets):
"""Lit exactement nb_octets sur le socket."""
morceaux = b""
while len(morceaux) < nb_octets:
morceau = sock.recv(nb_octets - len(morceaux))
if not morceau:
raise ConnectionError("Connexion fermee prematurement")
morceaux += morceau
return morceaux
def recevoir_message(sock):
"""Lit un message complet et renvoie l'objet Python."""
entete = recevoir_exactement(sock, TAILLE_ENTETE)
(taille,) = struct.unpack("!I", entete)
if taille > TAILLE_MAX:
raise ValueError(f"Message trop grand : {taille} octets")
charge = recevoir_exactement(sock, taille)
return json.loads(charge.decode("utf-8"))Comment s’en servir. Enregistrez ce code dans un fichier nommé exactement protocole.py. Tout autre fichier Python placé dans le même dossier peut alors en importer les fonctions :
from protocole import envoyer_message, recevoir_messageLe nom qui suit from est celui du fichier sans le .py. Python cherche le module dans le dossier du programme que vous lancez : c’est pourquoi les deux fichiers doivent être côte à côte. Si vous obtenez ModuleNotFoundError: No module named 'protocole', soit le fichier n’est pas dans ce dossier, soit il ne s’appelle pas exactement protocole.py — attention aux majuscules.
Le garde-fou TAILLE_MAX. Sans lui, un client malveillant (ou un bug) annonçant une taille de 4 milliards d’octets ferait tenter à votre serveur d’allouer 4 Go de mémoire, et le tuerait. Ne jamais faire confiance à un nombre venu du réseau.
L’envoi en une seule fois. Remarquez que l’on écrit sendall(entete + charge) plutôt que deux sendall successifs. C’est plus efficace, et cela évite un aller-retour réseau inutile.
3.6 Concevoir un protocole applicatif
Nous savons maintenant transporter des messages. Un protocole applicatif, c’est l’accord sur ce que ces messages veulent dire. Il définit trois choses :
- le vocabulaire : quelles commandes existent ;
- le format : quels champs contient chaque message ;
- l’enchaînement : qui parle, quand, et qui répond quoi.
Voici un exemple complet et minimal, que nous étendrons en séance 7.
Le vocabulaire
| Requête | Réponse | Rôle |
|---|---|---|
PING |
PONG |
Vérifier que le serveur est vivant |
CHARGE |
OK |
Demander la charge CPU |
CALCUL |
OK |
Demander un calcul |
QUIT |
(rien) | Fermer proprement |
| — | ERREUR |
Toute requête invalide |
Le format
Chaque message est un dictionnaire JSON possédant toujours un champ "type" :
# Requete
{"type": "CALCUL", "a": 12, "b": 30, "operation": "+"}
# Reponse en cas de succes
{"type": "OK", "resultat": 42}
# Reponse en cas d'echec
{"type": "ERREUR", "message": "operation inconnue"}Le champ "type" systématique permet d’ajouter de nouvelles commandes plus tard sans casser les clients existants : un client qui reçoit un type inconnu peut l’ignorer proprement. Les protocoles réels soignent énormément ce point, car on ne met jamais à jour tous les clients et tous les serveurs en même temps.
Le serveur
serveur_protocole.py
import socket
import threading
import os
from protocole import envoyer_message, recevoir_message
PORT = 50007
def traiter(requete):
"""Prend une requete (dict), renvoie une reponse (dict)."""
type_requete = requete.get("type")
if type_requete == "PING":
return {"type": "PONG"}
if type_requete == "CHARGE":
c1, c5, c15 = os.getloadavg()
return {"type": "OK", "charge": [c1, c5, c15]}
if type_requete == "CALCUL":
a, b = requete.get("a"), requete.get("b")
op = requete.get("operation")
if op == "+":
return {"type": "OK", "resultat": a + b}
if op == "*":
return {"type": "OK", "resultat": a * b}
return {"type": "ERREUR", "message": "operation inconnue"}
return {"type": "ERREUR", "message": "type inconnu"}
def servir_client(connexion, adresse):
try:
while True: # plusieurs echanges
requete = recevoir_message(connexion)
if requete.get("type") == "QUIT":
break
envoyer_message(connexion, traiter(requete))
except (ConnectionError, ValueError, OSError) as e:
print(f"Fin de session avec {adresse} : {e}")
finally:
connexion.close()
serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("0.0.0.0", PORT))
serveur.listen(64)
print(f"Serveur protocole en ecoute sur le port {PORT}")
while True:
connexion, adresse = serveur.accept()
threading.Thread(target=servir_client,
args=(connexion, adresse),
daemon=True).start()Le client
client_protocole.py
import socket
from protocole import envoyer_message, recevoir_message
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.settimeout(5.0)
client.connect(("127.0.0.1", 50007))
envoyer_message(client, {"type": "PING"})
print(recevoir_message(client)) # {'type': 'PONG'}
envoyer_message(client, {"type": "CHARGE"})
print(recevoir_message(client)) # {'type': 'OK', 'charge': [...]}
envoyer_message(client, {"type": "CALCUL",
"a": 12, "b": 30, "operation": "+"})
print(recevoir_message(client)) # {'type': 'OK', 'resultat': 42}
envoyer_message(client, {"type": "QUIT"})
client.close()Notez que la connexion reste ouverte pour plusieurs échanges. C’est plus efficace que de se reconnecter à chaque requête : établir une connexion TCP coûte un aller-retour réseau complet.
3.7 Vous connaissiez déjà ce problème : HTTP
HTTP, le protocole du Web, résout exactement les mêmes questions — et utilise les deux techniques de framing à la fois.
POST /api/utilisateurs HTTP/1.1
Host: exemple.fr
Content-Type: application/json
Content-Length: 27
{"nom": "Dupont", "age": 42}
- Les en-têtes sont délimités par des retours à la ligne (technique 1), et une ligne vide marque leur fin.
- Le corps du message est précédé de sa taille exacte, annoncée par
Content-Length: 27(technique 2).
Content-Length n’est pas une décoration : c’est très exactement la longueur préfixée de notre protocole.py. Sans elle, le serveur ne saurait pas où s’arrête le corps de la requête. Tous les protocoles applicatifs affrontent ce problème, et tous le résolvent de l’une de ces deux manières.
3.8 Faire évoluer un protocole
Un protocole n’est jamais figé. Un jour, vous voudrez ajouter un champ, renommer une commande, changer une unité. Et ce jour-là, vous ne pourrez pas mettre à jour toutes les machines en même temps : pendant quelques minutes — ou quelques mois — l’ancienne et la nouvelle version coexisteront.
Les deux compatibilités
| Compatibilité | Signification |
|---|---|
| ascendante (backward) | Du nouveau code sait lire des données écrites par de l’ancien code. |
| descendante (forward) | De l’ancien code sait lire des données écrites par du nouveau code. |
La compatibilité ascendante est facile : le nouveau code connaît l’ancien format, il lui suffit de prévoir une valeur par défaut pour ce qui manque.
La compatibilité descendante est plus délicate : l’ancien code doit tolérer ce qu’il ne comprend pas.
En pratique, avec JSON
# Version 1 du message
{"type": "MAP", "id_map": 7, "fichier": "part-007.txt"}
# Version 2 : on ajoute un champ
{"type": "MAP", "id_map": 7, "fichier": "part-007.txt",
"encodage": "latin-1"}Côté nouveau lecteur (ascendante) : toujours utiliser .get() avec une valeur par défaut.
encodage = requete.get("encodage", "utf-8") # bon
encodage = requete["encodage"] # casse sur l'ancien formatCôté ancien lecteur (descendante) : ignorer silencieusement les champs inconnus. C’est gratuit avec JSON… à condition de ne pas écrire de validation trop stricte qui rejetterait les clés inattendues.
Trois règles suffisent pour ne jamais casser un protocole en production :
- tout nouveau champ est optionnel et a une valeur par défaut ;
- on ne renomme ni ne supprime jamais un champ obligatoire (on en ajoute un nouveau, on renseigne les deux pendant la transition, on retire l’ancien plus tard) ;
- un champ inconnu est ignoré, jamais rejeté.
Texte ou binaire ?
JSON est du texte : lisible, universel, débogable avec un simple print. Son défaut est le volume et le coût d’analyse. Les systèmes à très grand débit utilisent des formats binaires à schéma, comme Protocol Buffers (Google), Thrift (Facebook) ou Avro (Hadoop).
| JSON | Binaire à schéma | |
|---|---|---|
| Taille | 3 à 10 fois plus gros | compact |
| Lisibilité | directe | nulle sans le schéma |
| Champs | désignés par leur nom | désignés par un numéro |
| Évolution | par convention | vérifiée par le schéma |
| Usage typique | API web, ce cours | stockage massif, RPC interne |
Leur astuce est simple et instructive : dans le message, un champ n’est pas désigné par son nom mais par un numéro fixé dans le schéma. On peut donc renommer un champ librement (le numéro ne change pas), mais jamais réutiliser un numéro pour autre chose — ce serait interpréter d’anciennes données avec le mauvais sens.
Ces questions se posent à chaque fois que des données franchissent une frontière, et il y en a trois : à travers une base de données (l’écrivain d’hier, le lecteur d’aujourd’hui), à travers un service en réseau (REST, RPC — notre cas), et à travers des messages asynchrones (files d’attente, Kafka). Dans les trois cas, émetteur et destinataire n’ont aucune raison d’être à la même version.
Voir Kleppmann & Riccomini, Designing Data-Intensive Applications, chapitre 5 (Encoding and Evolution).
3.9 Exercices
Exercice 3.1 — Reproduire le problème
Écrivez et lancez serveur_trois.py et client_trois.py de la première section. Notez ce que vous obtenez.
Puis ajoutez time.sleep(0.5) entre les trois sendall du serveur et relancez. Que se passe-t-il ? Expliquez en trois phrases.
Enfin, remplacez les trois petits messages par trois messages de 100 000 octets (b"A" * 100000) pour observer la fragmentation.
Attention : trois recv ne suffisent plus à tout lire. Remplacez-les par une boucle qui lit jusqu’à la fermeture, et affichez la taille de chaque morceau :
tailles = []
while True:
morceau = client.recv(65536)
if not morceau: # b"" : le serveur a raccroche
break
tailles.append(len(morceau))
print(len(tailles), "recv,", sum(tailles), "octets")
print(tailles)Exercice 3.2 — Écrire recevoir_exactement
Sans regarder le corrigé, réécrivez la fonction recevoir_exactement(sock, nb_octets).
Testez-la en écrivant un serveur qui envoie 10 000 octets octet par octet avec un sleep(0.0001) entre chaque, et un client qui les récupère tous. Vérifiez qu’il en reçoit exactement 10 000.
Exercice 3.3 — Le module protocole.py
Écrivez le fichier protocole.py complet (les trois fonctions).
Testez-le avec un client qui envoie successivement :
- un petit dictionnaire ;
- une liste de 50 000 nombres ;
- un dictionnaire dont une valeur est une chaîne contenant des retours à la ligne et des accents.
Vérifiez que les trois arrivent intacts.
Question. Votre module emploie la technique 2, la longueur préfixée. Supposez que vous ayez retenu la technique 1 à la place : aucune longueur annoncée, chaque message simplement terminé par un \n. Pourquoi le troisième cas poserait-il alors problème ?
Exercice 3.4 — Étendre le protocole
Ajoutez au serveur trois commandes :
{"type": "INFO"}→ renvoie le nom de la machine (socket.gethostname()), le nombre de cœurs (os.cpu_count()) et l’heure ;{"type": "ECHO", "message": ...}→ renvoie le message tel quel ;{"type": "SOMME", "nombres": [...]}→ renvoie la somme de la liste.
Chaque commande doit renvoyer ERREUR avec un message explicite si un champ manque ou est du mauvais type, sans jamais faire planter le serveur.
Exercice 3.5 — Le serveur robuste
Testez la solidité de votre serveur en écrivant des clients malveillants qui :
- se connectent puis ferment immédiatement, sans rien envoyer ;
- envoient un en-tête annonçant 1000 octets puis n’en envoient que 10, et attendent ;
- envoient 4 octets aléatoires suivis de n’importe quoi ;
- envoient un en-tête annonçant 3 milliards d’octets.
Après chacun de ces tests, votre serveur doit toujours répondre correctement à un client normal. Corrigez-le jusqu’à ce que ce soit le cas.
Le cas 2 est le plus instructif : ce client ne fait rien d’illégal, il se tait. Immobiliser ainsi les ressources d’un serveur pour l’empêcher de servir ses clients légitimes porte un nom : c’est une attaque par déni de service (denial of service).
Exercice 3.6 — Un client vers plusieurs serveurs
Combinez les séances 2 et 3. Il faut d’abord déployer le nouveau serveur : celui de la séance 2 envoyait du texte brut, celui-ci parle notre protocole.
1. Déployer, exactement comme à l’exercice 2.3 — une seule copie, puis une commande ssh par machine :
$ scp protocole.py serveur_protocole.py login@tp-1a201-05.enst.fr:~/
$ ssh login@tp-1a201-05.enst.fr \
"nohup python3 ~/serveur_protocole.py > /tmp/proto-\$USER.log 2>&1 &"Trois machines suffisent. Rappel : on ouvre trois connexions SSH à la main, pas une boucle qui en lance des dizaines.
2. Interroger. Écrivez un client qui, avec un ThreadPoolExecutor, envoie {"type": "CHARGE"} à chaque machine en utilisant protocole.py, puis affiche les machines de la moins chargée à la plus chargée.
Ajoutez à votre liste une machine éteinte et une machine allumée où vous n’avez rien déployé. Votre programme doit les signaler sans planter.
Question : ces deux cas d’échec ne se ressemblent pas du tout vus du client. Qu’observez-vous pour chacun, et pourquoi ?
3. Ranger.
$ ssh login@tp-1a201-05.enst.fr "pkill -f serveur_protocole.py"3.10 Corrigés
Sans sleep, le client reçoit presque toujours b'PREMIERDEUXIEMETROISIEME' en un seul recv, puis b'' deux fois.
Avec sleep(0.5), les trois messages arrivent en général séparément : le système n’a plus l’occasion de les regrouper, car chaque appel à sendall est suivi d’un silence suffisamment long pour que les octets partent seuls.
C’est précisément ce qui rend le bug dangereux : le comportement dépend du timing, donc de la charge de la machine et du réseau. Un code faux peut fonctionner mille fois puis échouer.
Avec des messages de 100 000 octets, on observe le phénomène inverse, la fragmentation. Relevé sur localhost :
6 recv, 300000 octets
[48996, 65536, 48788, 65328, 65536, 5816]Trois sendall de 100 000 octets sont ressortis en six recv, de tailles toutes différentes, et aucune ne correspond à une frontière de message. Les 300 000 octets sont tous là, dans le bon ordre : TCP a tenu sa promesse, et elle ne porte que sur les octets.
Avec les trois recv(1024) du départ, vous auriez lu 1024, 1024, 1024 — et rien d’autre. Vous n’auriez vu ni la fragmentation, ni les 296 928 octets restants, bien présents dans le tampon du système.
C’est déjà la leçon de la séance : on ne sait jamais à l’avance combien d’octets un recv va rendre, donc on boucle.
def recevoir_exactement(sock, nb_octets):
morceaux = b""
while len(morceaux) < nb_octets:
morceau = sock.recv(nb_octets - len(morceaux))
if not morceau:
raise ConnectionError("Connexion fermee prematurement")
morceaux += morceau
return morceauxTrois points sont indispensables et souvent oubliés :
- la condition de boucle porte sur le cumul, pas sur un seul appel ;
- on demande
nb_octets - len(morceaux), pasnb_octets, pour ne pas lire les octets du message suivant ; - le test
if not morceaudétecte la fermeture ; sans lui, la boucle tournerait indéfiniment.
Le test. Le serveur envoie 10 000 octets un par un, avec une pause minuscule qui empêche le système de les regrouper :
serveur_octet.py
import socket
import time
serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("127.0.0.1", 50007))
serveur.listen(1)
connexion, _ = serveur.accept()
for _ in range(10000):
connexion.sendall(b"X")
time.sleep(0.0001)
connexion.close()client_octet.py
import socket
from protocole import recevoir_exactement
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.settimeout(30.0)
client.connect(("127.0.0.1", 50007))
donnees = recevoir_exactement(client, 10000)
print("recus :", len(donnees))
assert len(donnees) == 10000 and donnees == b"X" * 10000
client.close()Le assert passe : on obtient bien 10 000 octets. Remplacez l’appel par un unique client.recv(10000) et vous n’en récupérerez qu’une poignée — ceux qui étaient arrivés à cet instant précis. C’est exactement le bug que la boucle élimine.
Le code est celui donné en cours. Les trois tests passent.
Le troisième cas (une chaîne contenant des retours à la ligne) aurait posé problème avec un framing par délimiteur : le lecteur aurait cru que le message se terminait au premier \n à l’intérieur de la donnée, et aurait coupé le message en deux. Il aurait fallu échapper les \n avant l’envoi et les restaurer à l’arrivée.
C’est l’avantage décisif de la longueur préfixée : elle est transparente au contenu. Aucun octet n’est interdit.
import socket, os
from datetime import datetime
def traiter(requete):
t = requete.get("type")
if t == "INFO":
return {"type": "OK",
"machine": socket.gethostname(),
"coeurs": os.cpu_count(),
"heure": datetime.now().isoformat()}
if t == "ECHO":
if "message" not in requete:
return {"type": "ERREUR", "message": "champ 'message' absent"}
return {"type": "OK", "message": requete["message"]}
if t == "SOMME":
nombres = requete.get("nombres")
if not isinstance(nombres, list):
return {"type": "ERREUR",
"message": "'nombres' doit etre une liste"}
try:
return {"type": "OK", "resultat": sum(float(x)
for x in nombres)}
except (TypeError, ValueError):
return {"type": "ERREUR",
"message": "valeurs non numeriques"}
return {"type": "ERREUR", "message": f"type inconnu : {t}"}La règle générale : traiter() ne doit jamais lever d’exception. On valide la présence des champs avec .get(), leur type avec isinstance, et on entoure les conversions d’un try/except.
Les quatre attaques tiennent en quelques lignes chacune :
clients_malveillants.py
import random
import socket
import struct
import time
from protocole import envoyer_message, recevoir_message
ADRESSE = ("127.0.0.1", 50007)
def fermeture_immediate(): # 1
s = socket.create_connection(ADRESSE)
s.close()
def entete_menteur(): # 2
s = socket.create_connection(ADRESSE)
s.sendall(struct.pack("!I", 1000) + b"A" * 10)
time.sleep(5) # on attend, sans rien envoyer
s.close()
def entete_aleatoire(): # 3
s = socket.create_connection(ADRESSE)
s.sendall(random.randbytes(4) + b"nimportequoi")
s.close()
def taille_demesuree(): # 4
s = socket.create_connection(ADRESSE)
s.sendall(struct.pack("!I", 3000000000))
s.close()
def client_normal(): # temoin : doit afficher PONG
s = socket.create_connection(ADRESSE)
envoyer_message(s, {"type": "PING"})
print(recevoir_message(s))
s.close()
for attaque in (fermeture_immediate, entete_menteur,
entete_aleatoire, taille_demesuree):
attaque()
client_normal()| Attaque | Comportement attendu |
|---|---|
| Fermeture immédiate | recevoir_exactement lève ConnectionError au premier recv (b""). Le thread l’attrape et ferme. |
| En-tête menteur (1000 annoncés, 10 envoyés) | Le serveur reste bloqué dans recv. Correction : connexion.settimeout(30) côté serveur aussi. |
| En-tête aléatoire | json.loads lève JSONDecodeError (sous-classe de ValueError), attrapée. |
| 3 milliards annoncés | Bloqué par le garde-fou TAILLE_MAX, qui lève ValueError. |
Pour l’attaque 3, quatre octets tirés au hasard annoncent en moyenne deux milliards d’octets : selon le tirage, c’est donc tantôt TAILLE_MAX qui intervient, tantôt json.loads qui échoue sur les octets suivants. Dans les deux cas le serveur survit.
La correction essentielle est d’ajouter un timeout côté serveur, sur le socket de chaque client :
def servir_client(connexion, adresse):
connexion.settimeout(30.0)
try:
...
except (ConnectionError, ValueError, OSError):
pass
finally:
connexion.close()Sans cela, un seul client malveillant peut immobiliser un thread indéfiniment ; quelques dizaines suffisent à épuiser le serveur. C’est une attaque par déni de service élémentaire.
import socket
from concurrent.futures import ThreadPoolExecutor
from protocole import envoyer_message, recevoir_message
PORT = 50007
MACHINES = ["tp-1a201-05.enst.fr", # serveur deploye
"tp-1a201-06.enst.fr", # serveur deploye
"tp-1a201-07.enst.fr", # serveur deploye
"tp-1a201-08.enst.fr", # allumee, rien de deploye
"tp-1a201-00.enst.fr"] # eteinte
def charge_de(machine):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3.0)
try:
s.connect((machine, PORT))
envoyer_message(s, {"type": "CHARGE"})
reponse = recevoir_message(s)
if reponse.get("type") == "OK":
return (machine, reponse["charge"][0], None)
return (machine, None, "reponse inattendue")
except socket.timeout:
return (machine, None, "timeout")
except ConnectionRefusedError:
return (machine, None, "connexion refusee")
except (OSError, ValueError, ConnectionError, KeyError) as e:
return (machine, None, type(e).__name__)
finally:
s.close()
with ThreadPoolExecutor(max_workers=10) as pool:
resultats = list(pool.map(charge_de, MACHINES))
repondu = sorted([(m, c) for m, c, _ in resultats if c is not None],
key=lambda x: x[1])
print("Machines qui ont repondu, de la moins chargee a la plus chargee :")
for machine, charge in repondu:
print(f" {machine:28s} {charge:5.2f}")
print("\nEchecs :")
for machine, charge, motif in resultats:
if charge is None:
print(f" {machine:28s} {motif}")Les deux échecs n’ont rien à voir. Mesuré sur les machines de l’école, avec un timeout de 3 secondes :
| Cas | Ce que fait le réseau | Vu du client |
|---|---|---|
| Machine éteinte | personne ne répond aux paquets | socket.timeout après 3,01 s |
| Machine allumée, rien déployé | le système répond « ce port est fermé » | ConnectionRefusedError en 0,01 s |
Une machine éteinte coûte cher en temps ; un port fermé ne coûte rien. C’est d’ailleurs pour cela qu’on ne peut pas déduire mon service répond-il ? de la machine est-elle allumée ? — les deux questions se distinguent même dans la nature de l’erreur.
Un troisième cas existe et ne se voit pas ici : un serveur démarré mais bloqué. La connexion s’établit, puis plus rien n’arrive. Seul le timeout vous sauve — et c’est le cas le plus difficile à diagnostiquer.
3.11 Ce qu’il faut retenir
- TCP transporte un flux d’octets, pas des messages. Les frontières entre messages n’existent pas pour lui.
recv(n)renvoie au plus n octets ;b""signifie connexion fermée.- Deux techniques de framing, et seulement deux : le délimiteur (
\n) et la longueur préfixée (struct.pack("!I", n)). - Un octet s’écrit avec deux chiffres hexadécimaux (
00àFF) ;0xsignale la base 16 ; le!impose l’ordre gros-boutiste. recevoir_exactement()est indispensable : sans elle, le code n’est correct que par chance.- Un serveur pose un timeout sur chaque connexion acceptée. Sans lui, un client qui se connecte puis se tait immobilise un thread indéfiniment, et quelques dizaines suffisent à provoquer un déni de service.
- Sérialiser = transformer un objet en octets. JSON pour ce cours ; jamais
pickledepuis le réseau. - Un protocole applicatif = vocabulaire + format + enchaînement.
- Un protocole évolue : tout nouveau champ est optionnel avec une valeur par défaut, on ne renomme ni ne supprime jamais un champ obligatoire, et un champ inconnu s’ignore.
- HTTP fait exactement la même chose :
Content-Lengthest une longueur préfixée.