Projet RPC
Le but de ce projet est de programmer un client et un serveur RPC, conformément aux spécifications suivantes :
- RFC 1831 : RPC: Remote Procedure Call Protocol Specification Version 2
- RFC 1832 : XDR: External Data Representation Standard
- RFC 1833 : Binding Protocols for ONC RPC Version 2
Voici les principaux objectifs pédagogiques de ce projet :
- lire et comprendre une documentation technique complexe décrivant un protocole réseau ;
- implémenter une application client & serveur en se conformant à la spécification du protocole.
Introduction
Le protocole RPC (Remote Procedure Call) permet d'effecuer des appels de procédure à distance, en utilisant les couches de transport TCP ou UDP.
Un programme RPC est un service réseau, identifié par un numéro de programme
prog
et un numéro de version vers
de ce programme. Ce programme propose des
procédures, également identifiées par un numéro entier proc
, qu'il est
possible d'exécuter à distance grâce au protocole RPC.
En simplifiant un peu, le client envoie auprès d'un serveur RPC (host, port)
une requête (xid, prog, vers, proc, args)
qui commande l'exécution de la
procédure (prog, vers, proc)
en lui passant les arguments args
au format
XDR. La procédure retourne un certain résultat result
au format XDR, qui est
ensuite transmis en réponse au client sous la forme d'un message (xid, result)
.
La variable xid
est un identifiant entier, qui permet de mettre en
relation la requête d'un client avec la réponse du serveur.
Prise en main de RPC
Dans ce projet, nous allons utiliser pour effectuer nos tests le programme RPC
test.x de numéro 0x20000001
(version 1
). On y trouve
plusieurs procédures de tests : pi()
, inc()
, add()
, echo()
. Par exemple,
la procédure add
a le numéro 3
dans ce programme RPC . Elle prend en
argument deux entiers (au format XDR) et retourne comme résultat la somme de ces
entiers (au format XDR). Par convention, le programme RPC dispose implicitement
d'une procedure null
(de numéro 0
), qui ne prend aucun argument (void
), ne
fait rien et ne retourne aucun résultat (void
).
Vous trouverez dans le fichier demo.zip une implémentation du
programme RPC test.x
écrite en langage C.
👉 Compilez et testez cette démo sur une machine du CREMI.
# compilation
$ make
# launch server
$ ./server
# launch client (in another terminal)
$ ./client localhost
👉 A l'aide de la commande rpcinfo -p
, identifiez le programme test.x
dans
l'annuaire rpcbind. Identifiez bien le numéro de ce programme, le numéro de
port associé (TCP ou UDP) à notre serveur. Notez que ce numéro de port est
choisi aléatoirement. Quel est le port d'écoute du service rpcbind ? Comment
le client fait-il pour retrouver notre serveur sur la machine localhost
? Quel
est l'avantage d'utiliser un tel annuaire ? Pourquoi le service rpcbind
(également appelé portmap) est-il lui même inscrit dans rpcbind ?
👉 Dans la démo précédente, vous pouvez relancer le serveur en lui imposant un
port d'écoute particulier : $ ./server 7777
. Vérifiez que cela fonctionne.
Wireshark
Voici une trace complète d'utilisation de notre programme de démo : trace.pcap, basée sur la couche transport UDP.
Nota Bene : Afin de permettre à Wireshark d'anlyser correctement les trames
RPC, il est nécessaire de sélectionner l'option Dissect unknown RPC program
numbers dans les préférences (menu Edit > Preferences > Advanced
). Ainsi, la
trace des programmes RPC inconnus (comme test.x) sont interprétés correctement
par Wireshark (cf. image ci-dessous).
Les 6 premières lignes correspondent à des appels de procédures auprès du
service rpcbind. En particulier, la trame n° 5 est un appel à la procédure
getaddr()
qui permet à un client de récupérer le numéro de port du serveur
test.x
. La réponse est donnée dans la trame n° 6 dans le champs Universal
Address.
👉 Quelle est la valeur de ce champs ? Comment la convertir en un numéro de port UDP ?
Les trames suivantes correspondent à des appels successif au différentes
procédures du programme test.x
. En particulier, la trame n° 13 est un appel à
la procédure add()
qui prend deux entiers en paramètres.
👉 Retrouvez la valeur des deux entiers, ainsi que la valeur du résultat dans la trame n° 14.
Travail Demandé
Il s'agit d'un travail à réaliser en équipe de 2 étudiants (choix du groupe sur Moodle).
Il est demandé de développer ce projet en langage Python 3, en se limitant à la couche de transport UDP, et en respectant rigoureusement les spécifications du protocole RPC (RFC 1831, 1832 et 1833).
Pour simplifier la réalisation de ce projet, nous allons le décomposer en plusieurs exercices intermédiaires, correspondant à des fichiers Python séparés à rendre sous Moodle/VPL.
Exercice 1 (XDR)
En vous conformant à la spécification RFC
1832 sur XDR, écrire les fonctions
encode()
& decode()
définies dans le fichier xdr.py. Ces
fonctions vont nous servir respectivement à encoder & décoder les arguments (et
le résultat) des procédures RPC. Plus précisement, les fonctions de la forme
encode_xxx()
permettent d'encoder une variable de type xxx
en bytes. A
l'inverse, les fonctions decode_xxx()
permettent de décoder des bytes en une
variable de type xxx
.
Pour simplifier notre projet, nous allons nous limiter aux types xxx
suivants :
int
: le type XDR integer ;uint
: le type XDR unsigned integer ;bool
: le type XDR boolean ;double
: le type XDR double-precision floating-point ;string
: le type XDR string ;two_int
: un type XDR structure avec 2 composantesint
.
Pour illustrer notre propos, voici un exemple d'utilisation des fonctions
encode_int()
et decode_int()
du module xdr.py
.
>>> import xdr
>>> xdr.encode_int(20)
b'\x00\x00\x00\x14'
>>> xdr.decode_int(b'\x00\x00\x00\x14')
20
Pour tester ce module, nous utilisons l'outil doctest, qui permet d'intégrer des tests directement dans les commentaires des fonctions de ce module (cf. Annexes). Pour lancer les tests, il suffit alors d'excuter la commande suivante :
$ ./xdr.py -v
👉 Pour réaliser cet exercice, il vous est demandé d'utiliser le module
xdrlib. Veuillez rendre votre
implémentation du fichier xdr.py
dans Moodle/VPL.
Avertissement : Attention, de ne pas modifier le nom des fonctions à implémenter ! En outre, ce fichier ne doit pas posséder de programme principal, mais juste le code des fonctions demandées !
Exercice 2 (RPC Message)
En vous conformant à la spécification RFC
1831 de RPC (version 2), il vous est
demandé d'écrire les fonctions encode()
& decode()
définies dans le fichier
rpcmsg.py.
encode_call(xid, prog, vers, proc, data) -> bytes
decode_call(msg: bytes) -> (xid, prog, vers, proc, data)
encode_reply(xid, prog, data) -> bytes
decode_reply(msg: bytes) -> (xid, data)
Ces fonctions vont permettre d'encoder et de décoder des messages RPC au format
XDR. Ces messages sont soit de type call (appel à une procédure), soit de type
reply (réponse à une procédure). Le message RPC se compose simplement d'un
header constitué de plusieurs champs de type XDR uint
décrivant l'appel de
la procédure (ou la réponse), immédiatement suivi des données utilisateurs (ou
data) représentant les arguments (ou le résultat) de la procédure sous forme
de bytes au format XDR.
Voici en résumé la structure d'un message de type call :
/* header */
uint xid; /* message id */
uint msgtype; /* message type (call=0) */
uint rpcvers; /* RPC version 2 */
uint prog; /* program number */
uint vers; /* program version */
uint proc /* procedure number */
uint cred[2]; /* auth none (0) */
uint verf[2] /* auth none (0) */
/* data */
bytes args; /* procedure arguments (xdr) */
De même, voici la structure d'un message de type reply (dans le cas d'une réponse acceptée avec succès) :
/* header */
uint xid; /* message id */
uint msgtype; /* message type (reply=1) */
uint reply_state; /* accepted (0) */
uint verf[2]; /* auth none (0) */
uint accept_state; /* success (0) */
/* data */
bytes result; /* procedure result (xdr) */
Nota Bene :
- Nous travaillons uniquement avec la version 2 du protocole RPC
(
RPC_VERSION=2
). - Les messages RPC sont entièrements constitués de champs encodés au format XDR, alignés sur des multiples de 4 octets, y compris pour les données utilisateurs (ou data) que sont les arguments et les résultats de ces procédures.
- Pour simplifier notre projet, nous allons uniquement utiliser RPC sans
authentification (mode
AUTH_NONE
= 0). - Concernant les messages de type reply, nous ne considérons que le cas d'un
message accepté (
MSG_ACCEPTED
) avec succès (SUCCESS
). Ainsi, nous ne mettrons pas en place la gestion d'erreurs du protocole RPC, dans le but de simplifier notre projet.
Pour lancer les tests doctest, il suffit d'excuter la commande suivante dans un terminal :
$ ./rpcmsg.py -v
👉 Pour réaliser cet exercice, il vous est encore demandé d'utiliser le module
xdrlib, en vous conformant
rigoureusement à la spécification RPC, décrivant notamment la structure des
messages call et reply. Veuillez rendre votre implémentation du fichier
rpc.py
dans Moodle/VPL.
Avertissement : Attention, de ne pas modifier le nom des fonctions à implémenter ! En outre, ce fichier ne doit pas posséder de programme principal, mais juste le code des fonctions demandées !
Exercice 3 (RPC Net)
👉 Il vous est demandé dans cet exercice d'implémenter les fonctions call()
et
reply()
dans le fichier rpcnet.py en vous servant du module
rpcmsg.py
défini dans l'exercice précédent, ainsi que de la bibliothèque
socket
.
Nota Bene : On s'appuiera obligatoirement sur la couche de transport UDP
de la bibliothèque socket, et
en particulier sur les fonctions sendto()
et recvfrom()
de cette
bibliothèque. Par ailleurs, on se limitera à l'envoi ou la réception de messages
courts (< 1500 octets), ce qui nous évite d'avoir à gérer une boucle de
réception.
La fonction call(host, port, xid, prog, vers, proc, args)
permet à un client
RPC d'effectuer un appel à la procédure proc
du programme prog
(version
vers
) auprès d'un serveur RPC à l'écoute sur host:port
, en lui passant des
arguments args
au format XDR. Cette fonction attend la réponse du serveur et
retourne le résultat de la procédure au format XDR. L'utilisation des socket
est entièrement masqué à l'intérieur de cette fonction.
Voici un exemple d'utilisation de la fonction call()
dans un code client du
programme test.x
.
import xdr
import rpcnet
HOST = "localhost"
PORT = 7777
TEST_PROG = 0x20000001
TEST_VERS = 1
PROC_ADD = 3
XID = 1000
args = xdr.encode_two_int(10, 20)
result = rpcnet.call(HOST, PORT, XID, TEST_PROG, TEST_VERS, PROC_ADD, args)
print("result =", xdr.decode_int(result))
De manière symétrique, la fonction reply(sserver, handle)
permet de traiter
côté serveur l'appel d'une procédure RPC émise par un client avec la fonction
call()
. La fonction reply()
prend en premier arguement une socket serveur
sserver
(UDP) qui est bind à un numéro de port donné, et en second argument
une fonction handle()
définie dans le code du serveur. Basiquement, la
fonction reply()
attend de recevoir un message de type call, puis le décode,
avant d'appeler la fonction handle(xid, prog, vers, proc, args)
fournie par
l'utilisateur. Cette fonction traite l'exécution de la procédure proc
et
renvoie un résultat au format XDR (bytes
). Ce résultat sera finalement envoyé
en réponse au client dans un message de type reply.
Voici un exemple d'utilisation de la fonction reply()
dans un code serveur.
import socket
import rpcnet
HOST=''
PORT=7777
def myhandler(xid, prog, vers, proc, args):
result = b''
print("=> call procedure", proc)
# implement here all procedures defined in test.x
return result
### server loop
sserver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0)
sserver.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sserver.bind((HOST, PORT))
while True:
rpcnet.reply(sserver, myhandler)
sserver.close()
Nota Bene : Afin de simplifier ce projet, il n'est pas demandé de gérer les erreurs, c'est-à-dire que l'on supposera que tout se passe toujours bien, sinon le programme est autorisé à terminer son exécution violemment. Lire la section sur la gestion des erreurs en Annexe.
👉 Veuillez rendre votre implémentation du fichier rpcnet.py
dans Moodle/VPL,
ainsi que votre dernière version du fichier rpcmsg.py
.
Exercice 4 (RPC Bind)
Le service rpcbind (ou portmap) est un service réseau standard (à l'écoute
sur le port 111 en TCP & UDP), qui joue le rôle d'un annuaire associant un
numéro de programme RPC (prog
) et le numéro de port (port
) du serveur local
hébergeant ce programme. Ce service est lui-même un programme RPC de numéro
100000
, spécifié dans la RFC 1833.
Nota Bene : Dans ce projet, nous utiliserons la version 4 du service
rpcbind (RPCB_VERS = 4
).
Lorsqu'un serveur RPC démarre, il enregistre dans le service rpcbind de la
même machine l'assocation (prog, port)
. Lorsqu'un client souhaite utiliser le
progamme RPC prog
hébergé sur la machine host
, il ne le contacte pas
directement, mais interroge d'abord le service rpcbind sur host:111
afin de
découvrir le numéro de port (port
) du serveur RPC à contacter. Ce n'est alors
que dans un deuxième temps que le client peut envoyer un message vers
host:port
.
👉 En vous conformant à la RFC 1833, il vous est demandé d'implémenter dans le
fichier rpcbind.py la fonction getport(xid, prog, vers)
, qui
retourne le numéro de port du serveur local hébergeant le programme prog
(version vers
). Pour ce faire, il faut appeller la procédure GETADDR
du
service rpcbind, qui renvoie une adresse dite universelle, de la forme
"127.0.0.1.30.97"
, ce qu'il faut traduire comme l'adresse IP 127.0.0.1
et le
numéro de port 7777
(car 30*256+97=7777
).
👉 Implémentez les fonctions register()
et unregister()
dans le
fichier rpcbind.py, qui servent respectivement à inscrire
(procédure SET
) un nouveau service RPC auprès de rpcbind, ou à le
désinscrire (procédure UNSET
). Ces fonctions sont essentiellement utiles pour
écrire le code d'un serveur RPC.
Ces trois procédures RPC prennent en argument la structure rpcb
décrite comme
ceci dans la RFC :
struct rpcb {
uint prog; /* program number */
uint vers; /* version number */
string netid; /* network id */
string uaddr; /* universal address */
string owner; /* owner of this service */
};
En fonction de la procédure que vous utilisez, certains champs de type string
sont ignorés (se référer à la documentation de ces procédures dans la RFC). Dans
ce cas, il suffit d'affecter ce champs à la chaîne vide ""
au format XDR. En
particulier, le champs netid
prendra la valeur "udp"
dans notre projet, et
le champs owner
sera toujours ignoré.
Nota Bene : Attention, si un certain programme est déjà enregistré dans
rpcbind, il n'est pas possible de l'enregistrer une deuxième fois avec un
numéro de port différent. Il faut d'abord supprimer ce programme de rpcbind.
Pour le serveur de démo écrit en C (auth unix), vous pouvez utiliser la
commande en ligne : rpcinfo -d <prog> <vers>
. Pour un serveur écrit en Python
(auth none), il faudra obligatoirement utiliser votre fonction unregister()
.
Voici un exemple d'utilisation de ces procédures. Pensez à utiliser la commande
en ligne rpcinfo -p
pour consulter les entrées dans l'annuaire rpcbind
de la
machine locale et vérifier que ce test fonctionne !
import rpcbind
# register prog 0x30000001 (version 1) with port 4001
ret1 = rpcbind.register(1, 0x30000001, 1, 4001)
# register prog 0x30000002 (version 1) with port 4002
ret2 = rpcbind.register(2, 0x30000002, 1, 4002)
# unregister prog 0x30000002 (version 1)
ret3 = rpcbind.unregister(3, 0x30000002, 1)
# get port of program 0x30000001 (version 1)
port = rpcbind.getport(4, 0x30000001, 1)
print("port =", port)
👉 Veuillez rendre votre implémentation du fichier rpcbind.py
dans Moodle/VPL.
Pour l'évaluation de cet exercice dans VPL, une implémentation des modules
xdr
, rpcmsg
et rpcnet
est fournie implicitement. Cela vous permet de
réaliser cet exercice même si vous n'avez pas terminé les exercices précédents.
Exercice 5
Dans ce dernier exercice, il est demandé d'implémenter dans le fichier
server.py le serveur de démo test.x
présenté en introduction
en s'appuyant sur les modules développés dans les exercices précédents. Ce
serveur devra fournir exactement les mêmes procédures que le serveur RPC de démo
test.x, dont voici la description :
- procédure 0 :
void null(void)
, une procédure par défaut qui ne fait rien et ne renvoie rien (comme un ping RPC) ; - procédure 1 :
double pi(void)
retourne la valeur3.1415926
; - procédure 2 :
int inc(int x)
retournex+1
; - procédure 3 :
int add(int x, int y)
retournex+y
; - procédure 4 :
string echo(string s)
retournes
.
Ce programme server.py
prend en argument le port d'écoute UDP (par exemple
7777) et doit s'enregistrer dans l'annuaire rpcbind avec le numéro de
programme 0x20000001
(version 1
).
Ainsi, il faut lancer la commande python3 server.py 7777
pour démarrer le
serveur à l'écoute sur le port 7777. Pour tester ce serveur avec la procédure
add
(3), vous pouvez utiliser le code client déjà présenté dans l'exercice
précédent. Il faudra adapter ce code client pour tester l'ensemble des
procédures de votre serveur. (Idéalement, le client utilise la fonction
getport()
du module rpcbind
pour découvrir le port d'écoute du serveur.)
👉 Veuillez rendre votre implémentation du fichier server.py
dans Moodle/VPL.
Pour l'évaluation de cet exercice dans VPL, une implémentation des modules
xdr
, rpcmsg
et rpcnet
est fournie implicitement. Cela vous permet de
réaliser cet exercice même si vous n'avez pas terminé les exercices précédents.
Annexes
Python Tips
Il vous sera souvent nécessaire de convertir des chaînes de caractères (type str) en tableau d'octets (type bytes), car c'est ce dernier type qui est utilisé pour envoyer ou recevoir des données sur le réseau avec le module socket.
>>> s = "hello" # type str
>>> c = s.encode("ascii") # c = b"hello" (type bytes)
Et inversement :
>>> c = b"hello" # type bytes
>>> s = c.decode("ascii") # s = "hello" (type str)
Accessoirement, il est souvent plus lisible d'afficher une variable de type
bytes au format hexadecimal, en utilisant la fonction hex()
comme ceci :
>>> c = b'\x00\x00\x00\x14'
>>> c.hex()
'00000014'
Un peu d'aide sur les types standard en Python3 : https://docs.python.org/3/library/stdtypes.html
Utilisation de xdrlib
Voici un exemple d'utilisation du module
xdrlib pour encoder un uint
et un double
dans un buffer de type bytes
au format XDR et pour le décoder.
Notez que les données sont encodées les unes à la suite des autres dans ce
buffer, et qu'elles doivent être décodées dans le même ordre.
import xdrlib
# encode
p = xdrlib.Packer()
p.pack_uint(10)
p.pack_double(3.14)
data = p.get_buffer() # return buffer as 'bytes'
# decode
u = xdrlib.Unpacker(data)
val1 = u.unpack_uint() # val1 = 10
val2 = u.unpack_double() # val2 = 3.14
Gestion des erreurs (exception)
Concernant la gestion des erreurs, nous nous proposons de simplifier le
protocole RPC en terminant explicitement chaque traitement à la moindre erreur
détectée, sans envoyer de message d'erreur. En pratique, cela signifie que le
client doit terminer son exécution en appelant sys.exit(1)
. Une autre solution
élégante consiste à terminer le programme en émettant une Exception
, comme
ceci :
raise Exception("Omar m'a tuer.")
Pour le serveur, celui-ci doit interrompre le traitement de la requête en cours en affichage un message d'erreur 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 lors de
l'attente d'une réponse, vous pouvez 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'attraper simplement des erreurs, sans terminer l'exécution du programme. Par exemple :
try:
x = 10 / 0 # code à risque
except Exception as e:
# traitement d'une exception
print("ERROR:", e)
# retour à la normale
Installation de RPC sous Linux
Si vous souhaitez travaillez sur une machine Linux de type Debian & Ubuntu, vous devez installer quelques paquets avant de démarrer votre projet. Au CREMI, ces paquets sont déjà installés. Il n'est donc pas nécessaire d'effectuer cette installation.
# install rpcbind & rpcinfo
$ sudo apt install rpcbind
# install rpcgen
$ sudo apt install rpcsvc-proto
# install transport-independant RPC library (tirpc)
$ sudo apt install libtirpc3 libtirpc-dev
Tester une fonction avec doctest
La manière la plus rapide et la plus simple pour tester une fonction Python est d'utiliser l'outil doctest : https://docs.python.org/3/library/doctest.html.
Considérons à titre d'exemple le fichier sample.py
qui contient la fonction
add()
ci-dessous. Il est possible d'écrire un petit test dans la documentation
de cette fonction comme ceci.
def add(x, y):
"""
>>> add(1, 2)
3
"""
return (x+y)
Pour lancer ce test avec doctest
, il suffit alors de faire :
$ python3 -m doctest -v sample.py
Ou plus simplement avec la commande :
$ ./sample.py -v
à condition de rendre le script Python exécutable et d'ajouter une fonction main, comme ceci :
if __name__ == "__main__":
import doctest
doctest.testmod()