Projet RPC

Le but de ce projet est de programmer un client et un serveur RPC, conformément aux spécifications suivantes :

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 composantes int.

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 valeur 3.1415926 ;
  • procédure 2 : int inc(int x) retourne x+1 ;
  • procédure 3 : int add(int x, int y) retourne x+y ;
  • procédure 4 : string echo(string s) retourne s.

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()