Projet TFTP

Le but de ce projet est de programmer un client et un serveur TFTP, conformément à la spécification RFC 1350.

Présentation

Le protocole TFTP (Trivial FTP) est un protocole de transfert de fichier relativement simple, en comparaison du protocole FTP (Transfer File Protocol).

Ce protocole permet à un client TFTP de réaliser deux opérations basiques :

  • opération get : la récupération d'un fichier stocké sur le serveur distant sur la machine locale ;
  • opération put : la recopie d'un fichier stocké sur la machine locale vers le serveur distant.

Le protocole TFTP se base sur un modèle client/serveur en UDP, un peu particulier qui fonctionne de la manière suivante.

  • Le client (port source X) initie la communication en effectuant une requête auprès du serveur TFTP sur le port S=69 :
  • soit une Read Request (RRQ) pour demander au serveur d'envoyer un fichier au client ;
  • soit une Write Request (WRQ) pour permettre au client d'envoyer un fichier au serveur.
  • Le serveur répond au client (port destination X) en utilisant un port source éphémère Y (au lieu d'utiliser S), ce qui implique la création d'une nouvelle socket côté serveur, qui sera dédié au transfert du fichier.
  • Par la suite, le transfert du fichier va s'effectuer en utilisant uniquement le couple de ports éphémères (X,Y). Le fichier est découpé en bloc de données de 512 octets, chaque bloc étant identifié par un indice x (croissant, numéroté à partir de 1). Ainsi un bloc d'indice x sera transmis sous forme d'un message DATx.
  • Dans le cas RRQ, c'est le serveur qui doit envoyer un fichier au client. Pour ce faire, le serveur répond à la requête cliente en envoyant directement le premier bloc (message DAT1).
  • Dans le cas WRQ, c'est le client qui doit envoyer un fichier au serveur. Pour ce faire, le serveur répond à la requête cliente avec un acquittement ACK0, qui va déclencher côté client l'envoi du premier bloc (message DAT1).
  • Chaque message DATx doit être explicitement acquitté par un message ACKx, avant de pouvoir transmettre le bloc suivant d'indice x+1.
  • Le transfert se termine implicitement lors de l'envoi / acquittement du dernier bloc de données qui doit être d'une taille strictement inférieure à 512 octets (0 éventuellement, si le bloc précédent était de taille 512).

Le protcole TFTP, ainsi que le format des messages RRQ, WRQ, ACKx, DATx sont décrits précisément dans la RFC 1350.

Voici une illustration du protocole TFTP, extraite de Wikipedia :

Requête TFTP

On notera que le port S=69 du service TFTP est uniquement utilisé par le serveur pour recevoir la requête initiale RRQ ou WRQ. Le transfert s'effectue par la suite en utilisant uniquement un couple de ports éphémères (X,Y). Lorsqu'un client effectue une nouvelle requêtes, ce couple de ports va changer.

En complément, lisez attentivement les documents ci-dessous pour bien comprendre le fonctionnement du protocole TFTP :

  • Wikipedia : https://en.wikipedia.org/wiki/Trivial_File_Transfer_Protocol
  • RFC 1350 : https://tools.ietf.org/html/rfc1350

Modifications de la Spécification

Nous allons apporter quelques modifications à la spécification du protcole TFTP tel que défini dans la RFC 1350.

Diverses modifications

  • Notre serveur utilisera le port 6969 par défaut (au lieu du port 69).
  • Nous utiliserons uniquement le mode de transfert de type octet, pour transférer des données quelconques en format binaire (image, texte, ...). En pratique, cela revient à ouvrir le fichier au "format binaire", en passant le flag b à la fonction open(). Ainsi la lecture et l'écriture dans le fichier se fait en manipulant directement des byte-array plutôt que des str.
  • Le noms des fichiers échangés doivent être encodés en ASCII (pas de caractères spéciaux ou accentués) dans les requêtes.

Gestion des erreurs

Concernant la gestion des erreurs, nous nous proposons de simplifier le protocole TFTP en terminant explicitement chaque traitement à la moindre erreur détectée, sans envoyer de message d'erreur (opcode = 5). Par conséquent, il n'est pas demandé de mettre en place de mécanisme de reprise en cas de perte d'un bloc de données ou d'un acquittement. En pratique, cela signifie que le client doit terminer son exécution en appelant sys.exit(1). Pour le serveur, celui-ci doit interrompre le traitement de la requête en cours en indiquant explicitement le message ERROR sur sa sortie standard, mais le programme doit pouvoir continuer normalement son exécution et attendre de nouvelles requêtes.

Afin de ne pas bloquer indéfiniment l'exécution du programme client ou serveur lors d'un envoi ou d'une réception de message, il est demandé de mettre en place un mécanisme de timeout (en utilisant la fonction socket.settimeout()), qui produit une erreur après un délai de 2 secondes (valeur par défaut).

Afin de gérer au plus simples les différents types d'erreurs pouvant survenir lors de l'exécution des différents traitements, il est recommandé d'utiliser le mécanisme d'exception qui permet d'attrapper simplement des erreurs :

try:
    x = 10 / 0  # code à risque
except Exception as e:
    # traitement d'une exception
    print("ERROR:", e)
# retour à la normale

Gestion de la taille des blocs

En outre, nous allons considérer une extension du protcole TFTP avec l'option block size, en nous inspirant de la RFC 2348. Cette option permet de contrôler explicitement la taille du bloc de données transmis. Par défaut, un bloc de données a une taille de 512 octets. Cette option s'ajoute à la fin de la requête cliente RRQ ou WRQ. Par exemple, il faudra ajouter les octets suivants b'blksize\x001024\x00' pour positionner blksize à 1024. Si cette option n'est pas utilisée, il n'est pas nécessaire de la transmettre. Dans ce cas, la valeur par défaut reste 512.

Afin de simplifier un peu l'utilisation de cette option, nous considérons que la valeur de blksize choisie par le client est acceptée automatiquement par le serveur, sans qu'il soit nécessaire d'acquitter explicitement cette option avec un message OACK comme indiqué dans la spécification.

De plus, nous acceptons pour l'option blksize toute valeur strictement positive, même si pour des raisons de performance, il convient de proscrire des valeurs trop petites ou trop grandes.

Travail Demandé

Il s'agit d'un travail à réaliser en groupe de 2 étudiants.

Il est demandé de développer en langage Python 3 les programmes client et serveur, conformément à la description du protocole TFTP fournies ci-dessus, en incluant les modifications demandées. On s'appuiera obligatoirement sur la bibliothèque socket et sur la couche de transport UDP pour réaliser ce projet.

Plus précisément, on demande de programmer les commandes ci-dessous en respectant la syntaxe suivante :

# server
$ ./tftp-server.py -h
usage: tftp-server [-h] [-p PORT] [-t TIMEOUT] [-c CWD] [--thread]

# client
$ ./tftp-client.py -h
usage: tftp-client [-h] [-p PORT] [-t TIMEOUT] [-c CWD] [-b BLKSIZE] {get,put} host filename [targetname]

La commande cliente permet de réaliser au choix l'opération get ou put en se connectant au serveur host afin de transférer le fichier filename, dans un sens ou dans l'autre. Le client effectue le transfert d'un seul fichier à la fois. Optionnellement, il est possible de sauvegarder le fichier transféré sous un nom différent targetname.

Notons que les fichiers transférés sont lus et écrits depuis le répertoire courant des programmes client et serveur.

Voici la description des options :

  • L'option -h affiche l'aide sur la commande.
  • L'option -p PORT indique le numéro de port du serveur (par défaut, 6969).
  • L'option -t TIMEOUT indique le délai en secondes à partir duquel on considère que l'envoi ou la réception échoue (par défaut, 2).
  • L'option -c CWD permet de changer le répertoire courant dans lequel les fichiers (avec des chemins relatifs) sont lus ou écrits.
  • L'option -b BLKSIZE (côté client uniquement) indique la taille en octet du bloc de données utilisée pour transférer les fichiers (par défaut, 512).
  • L'option --thread (côté serveur uniquement) indique au serveur de traiter des requêtes clientes en parallèle, en déléguant chaque transfert de fichier à un thread particulier côté serveur (par défaut, False).

Pour vous aider à démarrer ce projet, nous vous fournissons la coquille du projet, qui se compose de trois fichiers :

  • le client tftp-client.py ;
  • le serveur tftp-server.py ;
  • le module tftp.py, qui regroupe l'implémentation des fonctions principales du client et du serveur : get(), put() et runServer().

Cette coquille réalise juste l'analyse des options et des arguments passées à ces commandes à l'aide du module argparse. Vous êtes libres d'adapter ces fichiers, mais vous devez respecter strictement la syntaxe imposée pour ces commandes.

N'essayez pas de réaliser tout le projet d'un coup, mais concentrez-vous sur les fonctionnalités principales, sans gérer les options ou les erreurs. Nous vous recommandons la progression suivante.

  1. En vous inspirant de l'exemple udp/echo (echo-server-udp.py et echo-client-udp.py), mettez en place la boucle principale du serveur et module tftp.
  2. Commencez par implémenter les requêtes RRQ et WRQ : le client forge la requête et l'envoie au serveur, tandis que ce dernier la décode (opcode, filename, mode) sans effectuer de transfert.
  3. Mettre en place le transfert de fichier dans un send ou dans l'autre, en utilisant un socket dédiée côté serveur.
  4. Ajoutez la gestion des erreurs en mettant en place un timeout sur les socket et en récupérant les diverses exceptions pour interrompre proprement les traitements en cours.
  5. Si (et seulement si) vos commandes réalisent correctement les fonctionnalités de base, ajoutez les fonctionnalités les plus avançées : targetname, blksize, thread.

👉 Rendez votre travail sur Moodle. L'étape 2 fera l'objet d'un rendu intermédiaire le 11 avril. Le rendu final est pour le 25 avril. Votre code sera évalué automatiquement avec VPL : il est donc impératif de respecter scrupuleusement les consignes !

Exemples

Commençons par lancer le serveur sur la machine myserver :

$ echo "hello world!" > hello.txt
$ ./tftp-server.py                  # waiting for client requests...

Depuis la machine myclient, lançons quelques commandes clientes et abservons les messages échangés.

  • Cas d'une opération get hello.txt (S=6969, X=60719 et Y=43581) :
$ ./tftp-client.py get myserver hello.txt
[myclient:60719 -> myserver:6969] RRQ=b'\x00\x01hello.txt\x00octet\x00'
[myserver:43581 -> myclient:60719] DAT1=b'\x00\x03\x00\x01hello world!\n'
[myclient:60719 -> myserver:43581] ACK1=b'\x00\x04\x00\x01'
  • Même opération, mais en enregistrant le fichier sous un autre nom :
$ ./tftp-client.py get myserver hello.txt bonjour.txt
  • Cas d'un opération put test.txt (S=6969, X=53809 et Y=33425) :
$ python3 -c 'print("A"*10 + "B"*10 + "C"*3)' > test.txt
$ cat test.txt
AAAAAAAAAABBBBBBBBBBCCC
$ ./tftp-client.py put myserver test.txt
[myclient:53809 -> myserver:6969] WRQ=b'\x00\x02test.txt\x00octet\x00'
[myserver:33425 -> myclient:53809] ACK0=b'\x00\x04\x00\x00'
[myclient:53809 -> myserver:33425] DAT1=b'\x00\x03\x00\x01AAAAAAAAAABBBBBBBBBBCCC'
[myserver:33425 -> myclient:53809] ACK1=b'\x00\x04\x00\x01'
  • Pour terminer, considérons la même opération avec l'option blksize=10, ce qui impose le découpage du fichier test.txt en trois blocs (S=6969, X=53199 et Y=54445) :
$ ./tftp-client.py -b 10 put myserver test.txt
[myclient:53199 -> myserver:6969] WRQ=b'\x00\x02test.txt\x00octet\x00blksize\x0010\x00'
[myserver:54445 -> myclient:53199] ACK0=b'\x00\x04\x00\x00'
[myclient:53199 -> myserver:54445] DAT1=b'\x00\x03\x00\x01AAAAAAAAAA'
[myserver:54445 -> myclient:53199] ACK1=b'\x00\x04\x00\x01'
[myclient:53199 -> myserver:54445] DAT2=b'\x00\x03\x00\x02BBBBBBBBBB'
[myserver:54445 -> myclient:53199] ACK2=b'\x00\x04\x00\x02'
[myclient:53199 -> myserver:54445] DAT3=b'\x00\x03\x00\x03CCC'
[myserver:54445 -> myclient:53199] ACK3=b'\x00\x04\x00\x03'

Python Tips

Voici un extrait de code illustrant comment décoder une requête de type WRQ.

frame = b'\x00\x02test.txt\x00octet\x00'          # sample of WRQ as byte array
frame1 = frame[0:2]                               # frame1 = b'\x00\x02'
frame2 = frame[2:]                                # frame2 = b'test.txt\x00octet\x00'
opcode = int.from_bytes(frame1, byteorder='big')  # opcode = 2
args = frame2.split(b'\x00')                      # args = [b'test.txt', b'octet', b'']
filename = args[0].decode('ascii')                # filename = 'test.txt'
mode = args[1].decode('ascii')                    # mode = 'octet'

Voici un extrait de code illustrant comment appeler une fonction process dans un thread.

import threading

def process(msg):
    print("[thread]", msg)

t = threading.Thread(None, process, None, ("hello", ))
t.start()   # start to execute process() in thread
...         # do what you want concurrently in the main process
t.join()    # wait end of thread (optional)

Un peu d'aide pour Python 3 :

  • Types standards, comme str : https://docs.python.org/3/library/stdtypes.html
  • Fonction open : https://docs.python.org/3/library/functions.html#open
  • Module socket : https://docs.python.org/3/library/socket.html
  • Module argparse : https://docs.python.org/3/library/argparse.html
  • Module threading : https://docs.python.org/3/library/threading.html