Date de remise: 10 juin 2014, 23:59:59
Robustesse de connexion et de transmission
Nous poursuivons le développement de l’application d’exécution de programmes à distance amorcée durant le TP1. Le but de ce TP est de développer, au niveau de l’application, des procédés d’échange de données entre le client et le serveur qui soit robuste aux pertes de connexion et aux erreurs de transmission.
Le premier de ces problèmes est inhérent au fait que l’internet est un milieu d’échange fluide, où le service et la connectivité ne sont pas garantis. Lorsqu’on se connecte à un serveur par le protocole TCP, les transmissions entre les pairs connectés sont garanties, à l’exception d’une panne de service. Ainsi, la perte de la connexion est une condition limite qui doit être gérée tant par le serveur que par le client. Cependant, pour certaines applications, on aimerait pouvoir bâtir sur une connexion TCP l’abstraction d’une connexion robuste. Une telle connexion, articulée par un protocole construit sur TCP, fait en sorte qu’en cas de perte de connexion, le client et le serveur font le nécessaire pour reprendre contact et poursuivre leurs échanges, sans que l’application ne s’en rende compte. De son côté, l’application ne subirait qu’un temps de transfert plus long que d’ordinaire dans ses échanges.
Le second de ces problèmes est typiquement réglé par le protocole TCP. En revanche, une connexion TCP fait absolument confiance à son pair. On peut donc concevoir qu’une telle connexion peut être la proie d’une attaque par interposition (man-in-the-middle). Imaginons que la véritable adresse du serveur n’ai pas été transmise au client, mais qu’il s’agisse plutôt de l’adresse d’un attaquant. Cet attaquant, lui, connaît l’adresse du serveur. Lorsque le client se connecte aux coordonnées qu’il connaît, il se connecte en fait à l’attaquant, qui lui se connecte au serveur. À partir de ce moment, l’attaquant peut voir toutes les données échangées entre le serveur et le client. Il peut même manipuler ces échanges de données.
En principe, la bonne manière de se protéger des attaques par interposition consiste en un usage judicieux d’authentification et de cryptographie forte, de manière à ce que les données échangées entre le client et le serveur ne signifient rien à l’attaquant, et qu’il ne puisse pas manipuler ces échanges sans se révéler au client et au serveur. Dans le cadre de ce TP, nous répondrons plutôt à l’attaque par une approche naïve de détection des “erreurs” et de fiabilité des échanges.
Contexte du travail
J’ai préparé une version modifiée des programmes resh
, reshd
, ltee
et
lcat
que vous devrez télécharger
ici. Le code des programmes se
trouve dans le sous-répertoire code
, alors que le sous-répertoire test
contient un script permettant de vérifier la fonctionnalité exigée pour le
TP1. Vous savez déjà utiliser les programmes du TP1; quant au script de test,
vous l’appelez depuis un terminal, où vous vous placez dans le sous-répertoire
test
avant d’invoquer:
python tp1.py ../code
Comparez alors la réponse attendue à celle obtenue pour déterminer à l’oeil si chaque test est réussi ou non. Au désarchivage, tous les tests devraient être réussis.
La principale modification apportée aux programmes en regard de la solution
du TP1 déjà publiée est que
les bases de l’abstraction d’une connexion robuste et persistente ont déjà été
mises en place. Le protocole bâti par-dessus TCP est développé à travers les
classes du module connection.py
. Tout le travail a réaliser pour le TP
consiste à modifier et augmenter le code de connection.py
. Cela implique que
le TP doit être réalisé en Python. Cela implique aussi que vous ne devez pas
modifier les autres modules du code distribué dans le sous-répertoire code
.
Lors de la correction, la version originale des autres modules sera utilisée,
ainsi que votre version du module connection.py
.
Aperçu de l’architecture de connection.py
Les classes du module connection.py
observent la hiérarchie suivante:
Connection
Peer
Client
Accepted
Server
La classe Connection
offre le service de base, qui consiste en un socket
local utilisé pour échanger les données avec la connexion robuste. Celle-ci
les échange à son tour avec une connexion TCP de diverses manières.
D’une part, les connection de type pair (classes dérivées de Peer
) sont
des canaux de communication d’octets. D’une part, une instance de Client
est
utilisée comme un socket auquel on applique l’opération connect()
;
réciproquement, une instance d’Accepted
équivaut à un socket obtenu d’un
appel à accept()
. D’autre part, les instances de Server
offrent des
méthodes pour réaliser le travail d’un socket de base d’un serveur, qui
peuvent accepter des connexions à de multiples clients. Alors qu’on manipule
les instances de Client
et Accepted
avec les méthodes send(chaine)
et
recv(nb_octets)
, on manipule Server
avec accept()
; toutes ces classes
ont finalement une méthode close()
.
Sous le capot, le travail de transfert des données entre l’interface (paire de
sockets locaux articulant un tunnel bidirectionnel de données) et la
véritable connexion TCP se fait dans un fil d’exécution (thread) parallèle.
Les connexions de type Peer
reçoivent des chaînes d’octets sur l’interface
de service (côté peer
du tunnel) et les propagent sur la connexion TCP;
simultanément, elles reçoivent des chaînes sur la connexion TCP et les
propagent sur l’interface de service. Quant aux connexions de type Server
,
elles acceptent des connexions de client via un socket TCP dûment mis en
place et renvoient ces connexions TCP emballées dans une instance de
Accepted
. Avant d’entreprendre la résolution des problèmes du TP, il est
sage de prendre le temps de bien regarder le code de connection.py
pour bien
le comprendre. On peut même l’explorer un peu en y ajoutant des énoncés
print
pour bien comprendre l’ordre des opérations convenant à émuler le
comportement des sockets.
Au moment de débuter le TP, évidemment, ces abstractions ne sont qu’une coquille enveloppant des sockets TCP tout simples: les données échangées sont vulnérables aux erreurs imputables à une attaque par interposition et les connexions ne sont pas persistentes aux pannes.
Procédé d’attaque
Le sous-répertoire mitm
du code distribué pour le
TP contient le module/script mitm.py
, qui
met en place une attaque d’interposition de base (affichage des messages
échangés entre client et serveur). Cette attaque peut aussi
être utilisée pour simuler une perte de connexion entre client et serveur.
Vous pourrez vous servir de ce module pour tester la robustesse et la
persistence de vos propres connexions, en créant des programmes à partir de
mitm.py
(soit en dérivant la classe ManInTheMiddle
, soit en copiant et
modifiant directement le fichier), implantant les méthodes appropriées pour
transformer les données échangées.
Invariants
À travers la résolution des problèmes qui suivent, vos solutions devraient vérifier les invariants suivants.
- Les tests du TP1 passent tous sans problème.
- Le serveur fonctionne contre de multiples clients concurrents.
- On peut faire de multiples transferts concurrents par les commandes
ltee
oulcat
.
Notez comment tous ces invariants concernent la “couche application”: les changements qu’on réalise au-dessous de cette couche ne doivent pas affecter la couche du dessus, si ce n’est que d’une raisonnable pénalité de performance.
Problème 1 – Ordre des messages
Le premier problèmes dont on se préoccupe est de livrer les octets envoyés sur la connexion dans l’ordre où ils ont été envoyés. La meilleure façon de garantir l’ordre de livraison est de diviser la chaîne d’octets en cadres et d’implanter l’algorithme de la fenêtre coulissante pour transférer ces cadres de manière fiable. Utilisez une taille de fenêtre de 4 tant pour l’envoi que pour la réception.
Problème 2 – Consistance des messages
Le second problème est de détecter que le cargo des cadres est bien livré sans erreur. Si on détecte une erreur dans le cargo, on peut alors utiliser la fenêtre coulissante implantée au problème 1 pour assurer son retransfert. Notez qu’on peut avoir tant des erreurs de caractères, où des octets transférés sont remplacés, que des erreurs de cadrage, où des sous-séquences entières de la chaîne d’octets transférée peuvent être tronquées. Ces erreurs de caractère et de cadrage peuvent aussi être combinées.
Pour l’évaluation, notez que le correcteur ne tentera pas de renverser votre procédé de détection d’erreur de manière à produire une information redondante cohérente avec un cargo modifié. Le cargo et l’information de détection pourront être modifiées par l’attaque par interposition, mais pas avec assez d’intelligence pour que l’inconsistance du paquet ne soit pas détectable par un procédé vu en classe.
Instructions pour la remise
- Le travail est exécuté par équipes de deux.
- Le travail doit être remis par courriel à l’une de ces deux adresses:
benoit@benoithamelin.com,
benoit.hamelin@usherbrooke.ca.
- Le sujet du courriel: IFT585 TP2
- Le corps du courriel doit contenir le nom des deux coéquipiers.
- Un fichier nommé
connection.py
doit être inclus en pièce jointe.
Évaluation
- Le travail est évalué sur 20 points:
- Conformité aux instructions de remise: 2 points
- Propreté de la sortie des programmes: 1 point
- Satisfaction des invariants et consignes de développement: 2 points
- Les mêmes tests fonctionnent pour les deux directions de transfert: 3 points
- Problème 1: 5 points
- Problème 2: 7 points
- La conformité du code aux exigences sera testée à l’aide de programmes externes basés sur l’attaque d’interposition dont le code de base est donné. Mettre en oeuvre des tests adéquats en regard de la spécification fait partie de votre travail.
- Le code ne sera pas examiné. Ses qualités intrinsèques ne sont pas évaluées. On s’attend à ce que la difficulté du problème cause des problèmes de maintenance intractables (ou, à tout le moins, douloureux) pour des programmes conçus avec négligence ou sans égard à la lisibilité.
Questions et réponses
-
Je suis coincé sur un bug dans l’exécution de
ltee
: le programme tente de lire 1079 caractères sur le socket alors que celui-ci en contient 6, puis 0. Toutes les commandes en dehors deltee
etlcat
fonctionnent (ps
,ps aux
,ls
,ls -l
,vmstat
,python -i
,cat
…).Premièrement, il faut marquer une distinction entre
ltee
etlcat
, ainsi que toutes les autres commandes: les premières utilisent le systèmereshd
/resh
d’une manière particulière qui demande une bonne attention. Alors que je n’ai pas eu de bug particulier aveclcat
,ltee
m’a donné du fil à retordre à cause de la nature concurrente du protocole de transfert robuste développé dansconnection.py
. En d’autres termes, le problème vient d’une condition de compétition entre le fil d’exécution principal et le fil d’exécutionPeer.run
, où se réalisent les transferts.Lorsque
ltee
envoie son dernier message viamsgs.send(...)
(ligne 55 deltee.py
), il a terminé son travail. La dernière chose que l’interpréteur Python fait avant de quitter est d’invoquerconn.close()
, dont l’exécution a été commandée sur toute sortie viaatexit.register()
. C’est alors la méthodeConnection.close()
qui est alors exécutée. Cette dernière ferme l’interface de service de l’instance deConnection
, puis attend la terminaison du fil d’exécutionPeer.run
.De son côté, ce fil est à l’écoute de l’interface de service et se rend compte de l’intention de fermer la connexion lorsqu’il ne reçoit plus de données sur ce socket. Que fait-il alors? Il termine le plus vite possible son exécution. Le problème auquel j’ai fait face survient si les transferts cachés dans la fenêtre d’envois n’ont pas été tous complétés.
Il faut donc réviser les conditions de sortie de la boucle infinie définie dans
Peer.run
. Lorsqu’on détecte l’intention de quitter via la réception de 0 octets surself.peer
, il ne faut pas interrompre la boucle sur-le-champ. Il faut plutôt lever un drapeau du genreconn_closing
et n’accepter de sortir de la boucle que siconn_closing
est vrai et que tous les messages cachés pour envoi ont été transmis et confirmés.