Exemple de client/serveur TCP
From Livre IPv6
Vue d'ensemble
Le client/serveur choisi est particulièrement simple quant à sa fonction de service, de façon à privilégier l'aspect réseau dans la présentation. Il calcule le nombre d'utilisateurs connectés sur une machine donnée. Plus précisément :
$ nbus bernays 3 user(s) logged on bernays $
Il se compose de cinq fichiers sources :
- nbus.h le fichier d'en-tête commun aux fichiers sources du client et du serveur
- nbus.c le fichier source principal du client
- open_conn.c le fichier source qui gère les connexions du côté client
- nbusd.c le fichier source du serveur propre au service
- serv_daemon.c le fichier source qui gère les connexions du côté serveur
Le client, prend en argument un nom de machine (ou une adresse numérique), le convertit en une adresse IPv4 ou IPv6 et ainsi envoie ses requêtes à un serveur.
Le fichier source du serveur, selon les options de compilation, génère un code IPv6 ou un code IPv4. Dans le premier cas, le serveur est à même de satisfaire les requêtes d'un client IPv6 mais aussi d'un client IPv4. Dans le second cas, seuls les clients IPv4 pourront être pris en compte.
Il faut noter qu'il existe deux modes de fonctionnement pour une machine à double pile IPv4 et IPv6, la See Le client/serveur nbus résume la situation. Soit les espaces d'adresses sont disjoints, et dans ce cas il faut deux serveurs, un qui écoute les requêtes IPv4 (nbus4d) et un qui écoute les requêtes IPv6 (nbus6d) (ou un seul serveur, nbusd, avec deux sockets IPv4 et IPv6 séparées). Soit l'espace d'adresse IPv4 est inclus dans l'espace IPv6 et dans ce cas il suffit d'un serveur IPv6 (nbus6d) qui recevra les requêtes venant en IPv4 comme une requête IPv6 venant d'une adresse "IPv4 mappée".
Suivant les systèmes le mode de fonctionnement est prédéfini, configurable globalement dans le noyeau, ou configurable séparément pour chaque socket IPv6. Ainsi en FreeBSD le mode par défaut est "espace partagé" (choix modifiable par "sysctl -w net.inet6.ip6.v6only=1") et modifiable pour chaque socket, en précisant tcp4, tcp6 ou tcp46 dans le fichier de configuration d'inetd, ou en utilisant "setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY,&val)" dans le code du serveur.
Deux mêmes programmes serveur ne peuvent s'exécuter en même temps sur une machine : chaque serveur réserve le port nbus (par un bind) et par suite si un serveur est lancé, tout autre serveur échouera avec une erreur "port en service" (EADDRINUSE). Cela peut être aussi vrai entre les deux types de serveurs, nbus4d et nbus6d sur une machine "double pile avec espace partagé", car les espaces de service TCP et UDP sont communs à IPv4 et IPv6 et un port en service l'est aussi bien en IPv4 qu'en IPv6 ; simplement si le port correspond à une socket PF_INET, une requête de connexion IPv6 sera rejetée avec une erreur "port inaccessible". Dans la réalité le comportement est plus complexe. Même en mode "double pile avec espace partagé", on peut avoir deux sockets, l'une PF_INET et l'autre PF_INET6, comme le montre le programme nbusd.
Le code des différents serveurs est très semblable, le choix est fait en donnant une option à la compilation : nbusd par défaut, nbus4d si on ajoute l'option -DIPV4,et nbus6d si on ajoute l'option -DIPV6.
Le fichier d'en-tête nbus.h ne contient que le nom du service correspondant.
#define SERVICE "nbus"
Ainsi doit-on trouver, par exemple, dans le fichier /etc/services des machines concernées, la ligne suivante :
nbus 20000/tcp
Le programme client nbus établit tout d'abord une connexion TCP avec la machine cible (donnée en argument à nbus) via la fonction open_conn (cette fonction sera décrite ci-après). Il lit ensuite un entier court, résultat du calcul du nombre d'utilisateurs connectés sur la machine cible, puis l'imprime.
On notera la présence de la macro ntohs rendue nécessaire du fait des représentations différentes des entiers selon les machines.
#include <stdio.h> #include <unistd.h> #include "nbus.h" extern open_conn(char *, char *); int main(int argc, char **argv) { int sock; short nu; if (argc != 2) { fprintf(stderr, "Usage: %s host\n", argv[0]); exit(1); } if ((sock = open_conn(argv[1], SERVICE)) < 0) exit(1); read(sock, (char *) &nu, sizeof(nu)); nu = ntohs(nu); if (nu == -1) { fprintf(stderr, "Can't read \"utmp\" on %s\n", argv[1]); exit(1); } if (nu) { fprintf(stdout, "%d user(s) logged on %s\n", nu, argv[1]); exit(0); } fprintf(stdout, "Nobody on %s\n", argv[1]); exit(0); }
Le serveur nbusd, quant à lui, lance en tâche de fond un processus "démon" qui exécutera la fonction de service nbus (lignes See à See ) à chaque requête d'un client. Ce processus démon est réalisé par la fonction serv_daemon.
#include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <utmp.h> #include <sys/socket.h> #include "nbus.h" #if defined(IPV6) #define FAMILY PF_INET6 #elif defined(IPV4) #define FAMILY PF_INET #else #define FAMILY PF_UNSPEC #endif extern serv_daemon(int, char *, void (*)(int), char *); void nbus(int); int main(void) { serv_daemon(FAMILY, SERVICE, nbus, NULL); exit(0); } void nbus(int sock) { short nu = -1; #ifdef USER_PROCESS /* Solaris/Linux, use getutent */ struct utmp *up; up = getutent(); if (up != NULL) { for (nu = 0; up != NULL; up = getutent()) if (up->ut_type == USER_PROCESS) nu++; } endutent(); #else /* *BSD read directly utmp file */ #ifndef UTMP #define UTMP "/var/run/utmp" /* for FreeBSD/NetBSD */ #endif struct utmp ut; int fd; if ((fd = open(UTMP, O_RDONLY)) >= 0) { nu = 0; while (read(fd, (char *) &ut, sizeof(ut)) == sizeof(ut)) if (ut.ut_name[0]) nu++; } close(fd); #endif nu = htons(nu); write(sock, (char *) &nu, sizeof(nu)); return; }
L'établissement d'une connexion TCP, côté client
Comme on l'a vu plus haut dans le code du client nbus.c, l'établissement de la connexion TCP se fait au moyen de la fonction open_conn. Cette fonction prend en premier argument le nom de la machine avec laquelle on va établir la connexion TCP et en deuxième argument le nom du service tel qu'il apparaît dans le fichier /etc/services. La valeur retournée par open_conn est soit -1 en cas d'erreur, soit le descripteur associé à la socket réalisant la connexion. La figure Algorithme du client visualise l'algorithme employé.
La première étape sera la construction de l'adresse de la socket distante, ceci via la primitive getaddrinfo. On remarquera que le champ ai_family de la structure hints a été initialisé à la valeur PF_UNSPEC, ce qui signifie que, suivant que l'on donne en argument à la commande nbus un nom de machine (ou une adresse numérique) IPv4 ou IPv6, on travaillera avec une socket soit dans la famille des protocoles PF_INET, soit dans la famille des protocoles PF_INET6. Si on avait fait le choix de forcer la famille des protocoles à la valeur PF_INET6, il aurait fallu initialiser les champs ai_flags et ai_family respectivement aux valeurs AI_V4MAPPED et PF_INET6 (les adresses IPv4 seraient dans ce cas "mappées"). Si, comme dans l'exemple proposé, on ne fait pas ce choix, on notera qu'il n'y a aucune différence entre le code IPv4 et le code IPv6.
Ensuite, après avoir créé une socket, on la connecte au serveur de la machine cible (primitive connect). Là encore, le code est le même pour IPv4 et IPv6. Remarquons que getaddrinfo peut avoir rendu plusieurs valeurs. Un programme plus évolué devrait essayer de se connecter à chaque adresse rendue jusqu'à obtenir une réponse.
#include <stdio.h> #include <unistd.h> #include <sys/socket.h> #include <netdb.h> int open_conn(char *host, char *serv) { int sock, ecode; struct addrinfo *res; struct addrinfo hints = { 0, PF_UNSPEC, SOCK_STREAM, 0, 0, NULL, NULL, NULL }; ecode = getaddrinfo(host, serv, &hints, &res); if (ecode) { fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ecode)); exit(1); } if ((sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol)) < 0) { freeaddrinfo(res); perror("socket"); return -1; } if (connect(sock, res->ai_addr, res->ai_addrlen) < 0) { close(sock); freeaddrinfo(res); perror("connect"); return -1; } freeaddrinfo(res); return sock; }
Le serveur
Le serveur proprement dit est réalisé par une unique fonction. C'est la fonction serv_daemon qui a quatre arguments. Le premier est la famille d'adresse gérée, PF_INET ou PF_INET6, ou PF_UNSPEC pour écouter dans les deux familles. Le deuxième est, comme dans le cas de open_conn, le nom du service tel qu'il apparaît dans le fichier /etc/services. Le troisième argument est un pointeur vers la fonction de service dont le type (int(*)(void)) sera commenté ultérieurement. Enfin le dernier argument est le nom passé au démon syslogd lors de l'impression des messages d'erreur (ligne See ). Si le dernier argument est le pointeur nul, ce nom est par défaut le nom du service. Un aperçu du déroulement de la fonction serv_daemon est donné par la See Algorithme du serveur.
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <errno.h> #include <sys/socket.h> #include <netdb.h> #include <signal.h> #include <syslog.h> #include <sys/select.h> #include <sys/wait.h> static void reap_child(int); void serv_daemon(int family, char *serv, void (*serv_funct)(int), char *serv_logname) { int sock[2], ecode, n = 0; struct addrinfo *res, *rres, hints; memset(&hints, 0, sizeof hints) ; hints.ai_flags = AI_PASSIVE; hints.ai_socktype = SOCK_STREAM; hints.ai_family = family; ecode = getaddrinfo(NULL, serv, &hints, &rres); if (ecode) { fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ecode)); exit(1); } for (res = rres; res; res = res->ai_next) { if (n == 2) { /* au plus 2 : anyaddr PF_INET et anyaddr PF_INET6 */ fprintf(stderr, "erreur interne: trop d'adresses\n"); exit(1); } sock[n] = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if (sock[n] < 0) { perror("socket"); exit(1); } if (bind(sock[n], res->ai_addr, res->ai_addrlen) < 0) { perror("bind"); exit(1); } listen(sock[n], SOMAXCONN); n++; #ifdef __linux__ /* En Linux, utiliser seulement la premiere * reponse, sinon on a un conflit sur bind */ break; #endif } freeaddrinfo(rres);
La première étape consiste en la préparation de l'adresse de la socket d'écoute du serveur en faisant appel, comme d'habitude, à la primitive getaddrinfo. Comme il s'agit d'une socket d'écoute, le champ ai_flags de la structure hints a été initialisé à la valeur AI_PASSIVE tandis que le champ ai_family de cette même structure a lui été initialisé à la valeur passé en argument à l'appel.
Pour chaque réponse de getaddrinfo (une seule si la famille est définie, deux si la famille est PF_UNSPEC), on crée une socket d'écoute, puis effectue son attachement en faisant appel à la primitive bind. La socket d'écoute créée et attachée à une adresse, le serveur doit signifier au système qu'il est prêt à accepter les demandes de connexions. C'est l'objet de la primitive listen dont le deuxième argument SOMAXCONN (macro-définition que l'on trouve dans le fichier sys/socket.h) est le nombre maximal de connexions pendantes.
Dans les lignes qui suivent (See à See ), le processus est détaché du terminal de contrôle et est lancé en tâche de fond (processus "démon"), sauf en Solaris, le code étant trop différent, si le fichier source n'est pas compilé avec l'option -DDEBUG (dans le cas contraire, cela peut être bien utile lors de la phase de mise au point).
#ifndef DEBUG #ifndef sun /* no daemon function in Solaris */ if (daemon(0, 0) < 0) { perror("daemon"); exit(1); } #endif #endif
Dans la boucle sans fin (lignes See à See ), on attend les connexions. Comme il peut y avoir plusieurs sockets actives, on utilise select (lignes See à See ) pour attendre sur toutes les sockets ouverts. Au retour de select, on teste les sockets qui ont des connexions pendantes (ligne See ), chaque connexion pendante dans la file d'attente associée à la socket d'écoute est extraite par le serveur et le circuit virtuel avec la socket du client est établi via la création d'une nouvelle socket. Ce travail est effectué par la primitive accept dont la valeur de retour est le descripteur d'E/S de la socket nouvellement créée. Ensuite le serveur crée un processus fils (ligne See ) qui exécutera la fonction de service à laquelle on passe en argument la valeur retournée par la primitive accept.
Il faut également veiller à ce que chaque processus fils, lors de sa terminaison (qui se produit à la fin de l'exécution de la fonction de service), ne devienne un processus "zombie", ce qui à terme peut provoquer une saturation de la table des processus. Pour cela il faut, sous UNIX BSD, capter le signal SIGCHLD (ligne See , fonction reap_child). Notons qu'en SYSTEM V, on pourrait simplement ignorer le signal SIGCLD.
signal(SIGCHLD, reap_child); if (!serv_logname) serv_logname = serv; openlog(serv_logname, LOG_PID | LOG_CONS, LOG_USER); for (;;) { int a, f, len, fd, m = -1; struct sockaddr_storage from; fd_set fdset; FD_ZERO(&fdset); for (fd = 0; fd < n; fd++) { if (m < sock[fd]) m = sock[fd]; FD_SET(sock[fd], &fdset); } ecode = select(m+1, &fdset, NULL, NULL, NULL); if (ecode < 0) syslog(LOG_ERR, "%s: select: %m", serv); if (ecode <= 0) break; for (fd = 0; fd < n; fd++) { if (FD_ISSET(sock[fd], &fdset)) { len = sizeof from; a = accept(sock[fd], (struct sockaddr *)&from, &len); if (a < 0) { if (errno != EINTR) syslog(LOG_ERR, "%s: accept: %m", serv); continue; } f = fork(); if (f == 0) { /* Par correction, il faudrait fermer dans le fils * tous les descripteurs hérités (i.e. tous sauf a) */ serv_funct(a); exit(0); } close(a); if (f == -1) { syslog(LOG_ERR, "%s: fork: %m", serv); continue; } } } } } static void reap_child(int sig) { int status; while (wait3(&status, WNOHANG, NULL) > 0); }
Une dernière remarque toujours à propos de la similitude des codes IPv6 et IPv4 : la seule différence dans le code de serv_daemon entre IPv4 et IPv6 est la valeur du premier argument, qui définit la famille d'adresses écoutée.