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 portS=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èreY
(au lieu d'utiliserS
), 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 messageDATx
. - 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 (messageDAT1
). - 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 acquittementACK0
, qui va déclencher côté client l'envoi du premier bloc (messageDAT1
). - Chaque message
DATx
doit être explicitement acquitté par un messageACKx
, 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 :
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 flagb
à la fonctionopen()
. 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()
etrunServer()
.
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.
- 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.
- Commencez par implémenter les requêtes
RRQ
etWRQ
: 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. - Mettre en place le transfert de fichier dans un send ou dans l'autre, en utilisant un socket dédiée côté serveur.
- 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.
- 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
etY=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
etY=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 fichiertest.txt
en trois blocs (S=6969
,X=53199
etY=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