Réseau
Introduction
Les ordinateurs sont de plus en plus connectés entre eux afin d’échanger des données de toute sorte. Alors que la connexion physique lie deux ordinateurs (technologie avec ou sans fil), les données qui transitent partent d’un programme et arrivent à un autre. Ce chapitre traite des différentes manières de connecter deux programmes pour leur permettre de communiquer. Il s’appuie sur quelques notions de protocoles réseau dont nous allons revoir ici les grandes lignes.
1. Les couches réseau
Avant d’aborder les protocoles TCP/IP et UDP/IP utilisés dans ce chapitre, nous vous proposons de réviser quelques bases concernant le réseau.
Lorsqu’une application envoie des données à une autre via le réseau, ces données traversent plusieurs couches logicielles ou matérielles où elles subissent des traitements nécessaires à leur bonne distribution. À chaque passage dans une couche, les données sont encapsulées dans un paquet qui est transmis à la couche suivante. Nous pouvons comparer cela à la distribution d’un courrier de la part d’une personne A pour une personne B. La personne A place le courrier dans une enveloppe avec l’adresse du destinataire B, puis transmet la lettre à la couche suivante représentée par les services postaux. En simplifiant, ceux-ci mettent l’enveloppe dans un sac à destination de la ville de la personne B. Puis ils transmettent le sac à un agent de transport, par train, camion, avion ou autre. Lorsque le sac arrive à destination, les services postaux extraient la lettre à destination de la personne B et la lui transmettent par l’intermédiaire du facteur. Et Monsieur B va décapsuler le paquet, c’est-à-dire qu’il va ouvrir l’enveloppe et en extraire le courrier.
Le modèle ISO (OSI, Open Systems Interconnections) est un document datant de 1984 qui définit sept couches réseau. La figure suivante illustre ce modèle. Lorsqu’un message est envoyé d’une machine à une autre, elle traverse les couches depuis la plus haute vers la plus basse sur la machine émettrice, et remonte les couches pour aboutir à la plus haute sur la machine réceptrice.
Voici...
Créer un serveur TCP/IP
Problème
Vous souhaitez que votre application soit accessible par le réseau en lui donnant la fonctionnalité de serveur TCP/IP.
Solution
Écrivez une fonction qui crée une socket, lui donne une adresse, et la mette en écoute. Après appel à cette fonction, utilisez accept() pour gérer les demandes de connexion.
Discussion
La création d’un serveur s’effectue en trois étapes : la création de la socket avec socket(), l’affectation d’une adresse avec bind() et la mise en écoute avec listen().
C’est lors de la création de la socket que nous spécifions que le protocole souhaité est TCP/IP en choisissant PF_INET pour le protocole de communication et SOCK_STREAM pour son type. Ces deux paramètres impliquent TCP/IP qui est la seule possibilité. Le troisième paramètre, qui permet de choisir entre les protocoles possibles ne présente donc aucun intérêt ici.
Donner une adresse à la socket consiste, dans la terminologie traditionnelle, à attribuer un nom à la socket. Cela fait référence aux sockets locales qui ont effectivement un nom dans le système de fichiers. Ici, le nom n’est rien d’autre que l’adresse et le numéro de port sur lequel la socket va écouter. En indiquant INADDR_ANY, le serveur écoutera sur toutes les adresses disponibles sur la machine. En indiquant seulement une adresse, seuls les clients tentant de se connecter à cette adresse y parviendront. Sur une machine disposant de plusieurs interfaces, les tentatives de connexions...
Créer un client TCP/IP
Problème
Vous souhaitez vous connecter à un serveur TCP/IP.
Solution
Pour les tests, la commande telnet suffit amplement. Pour l’implémentation dans un programme, nous créons une socket avec socket() que vous connectez avec connect().
Discussion
Comme lors de la création d’un serveur, la fonction socket() permet de créer la socket. La connexion s’effectuant avec connect(), il faut préparer le second argument de cette fonction, avec l’adresse du serveur auquel se connecter, ainsi que son port. Pour cela, la fonction gethostbyname() nous est utile car elle transforme les adresses IP aussi bien que les noms de domaines en une structure (struct hostent) contenant les champs au format adéquat. Il ne reste ensuite plus qu’à les recopier dans la structure struct sockaddr_in.
Voici le code d’une fonction qui effectue la connexion au serveur distant et renvoie un identifiant de socket lorsque celle-ci est effective.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <netdb.h>
int
create_tcp_client (const char *hostname, int port)
{
struct hostent *host_address;
struct sockaddr_in sockname;
int optval;
int socket_id; ...
Créer un client et un serveur UDP/IP
Problème
Vous souhaitez que votre application soit accessible par le réseau en lui donnant la fonctionnalité de serveur UDP/IP ; ou vous avez besoin de vous connecter à un tel serveur.
Solution
Utilisez la fonction socket() pour créer le serveur comme le client. Le serveur effectue un appel à bind(). Puis le premier échange consiste en un sendto() de la part du client et en un recvfrom() du serveur. Ces deux fonctions servent aux échanges suivants, le serveur et le client utilisant alternativement l’une et l’autre de ces deux fonctions, au gré de la direction des échanges.
Discussion
La création d’un serveur et d’un client UDP, au contraire de TCP, ne nécessite que la mise en place de la socket et sa configuration du côté du serveur. Nous n’avons pas besoin de mettre le serveur en écoute ni même de connecter le client au serveur étant donné que ce protocole n’est pas un protocole connecté. Une fois les sockets créées et, dans le cas du serveur, configurées, il suffit au serveur d’attendre un message, que le client lui envoie.
Concrètement, un serveur se met en place de cette manière :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
int
create_udp_server (int port)
{
int socket_id;
struct sockaddr_in sockname;
int optval;
/* Création d'une socket. */
if (-1 == (socket_id = socket (PF_INET, SOCK_DGRAM, 0)))
{
fprintf (stderr, "Impossible de créer la socket\n");
exit (EXIT_FAILURE);
}
/* Changement d'un paramètre de la socket pour permettre une
* réutilisation immédiate après sa fermeture.
*/
optval = 1;
setsockopt (socket_id, SOL_SOCKET, SO_REUSEADDR, &optval,
sizeof (int));
/* Affectation d'une adresse. */
memset ((char *) &sockname...
Sécuriser une connexion avec SSL/TLS
Problème
Pour sécuriser une connexion TCP/IP, pour garantir la confidentialité, l’intégrité des données et leur authenticité, vous voulez l’encapsuler dans un flux SSL.
Solution
Utilisez l’API d’OpenSSL et encapsulez la connexion dans un tunnel SSL ou TLS.
Discussion
Avant toute chose, le programmeur doit être conscient que l’utilisation d’un chiffrement des données par SSL pour leur transport sur le réseau ne sécurise en rien une application. Le seul apport d’une connexion sécurisée est que les données ne circulent pas en clair entre l’émetteur et le récepteur, et qu’elles peuvent être authentifiées. En aucun cas cela n’empêche les attaques classiques contre une application, ce dont il faudra se protéger.
Un pré-requis de cette recette est de posséder une clé privée et un certificat. Leur génération débordant du cadre de ce livre, nous supposerons que vous disposez déjà de deux fichiers, à savoir la clé privée dans privkey.pem et le certificat dans server.pem. Pour comprendre la signification du contenu de ces deux fichiers, nous allons prendre un exemple. Lorsque vous vous connectez à un serveur sécurisé avec SSL, celui-ci se présente à l’aide d’une fiche de renseignements qui a été validée par une autorité de certification (CA). Cette fiche s’appelle le certificat. Si les renseignements qui vous sont présentés vous inspirent confiance, vous pouvez utiliser la clé publique du serveur, contenue dans la fiche. Vous pouvez la comparer à une valise ouverte dont la serrure vous permettrait de la fermer, mais seul le possesseur de la clé de la serrure pourrait l’ouvrir. Placez un message secret quelconque dans cette valise, refermez-la, et demandez au serveur de vous dire le contenu du message. S’il en est capable, vous êtes alors sûr que le serveur sécurisé est bien celui que vous pensez.
Si vous n’avez pas de clé privée et de certificat, vous pouvez vous référer à la documentation, abondante en particulier sur Internet, concernant la création...
Connaître le nom et l’adresse IP de ma machine
Problème
Vous voulez connaître le nom et l’adresse IP de la machine qui exécute le programme.
Solution
Utilisez gethostname() pour le nom et gethostbyname() pour l’adresse IP.
Discussion
La fonction gethostname() prend en premier argument un espace pré-alloué pour y placer le nom de la machine qui exécute le programme. Le second argument est la taille de l’espace mémoire. La limite d’un nom est défini par la constante MAXHOSTNAMELEN et il est donc inutile de disposer d’un espace mémoire plus grand que cela.
L’exemple suivant obtient le nom de la machine qui exécute le programme, et fait appel à gethostbyname() pour en obtenir la liste des adresses IP. Nous appliquons en fait la recette suivante ici.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/param.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int
main ()
{
struct hostent *host_entry;
char hostname[MAXHOSTNAMELEN];
int i;
if (-1 == gethostname (hostname, MAXHOSTNAMELEN))
{
fprintf (stderr, "Échec de gethostname()\n");
exit (EXIT_FAILURE);
} ...
Connaître l’adresse IP d’une machine à partir de son nom
Problème
Vous disposez du nom d’une machine et souhaitez connaître son adresse IP à partir de celui-ci.
Solution
Utilisez gethostbyname().
Discussion
La fonction gethostbyname() permet d’obtenir des informations sur la machine dont nous fournissons le nom. Parmi ces informations se trouve la liste des adresses IP de la machine que la fonction inet_ntoa() permet de transformer en chaîne de caractères. Voici un exemple :
#include <stdio.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int
main (int argc, char **argv)
{
struct hostent *host_entry;
int i;
if (argc < 2)
{
fprintf (stderr, "Précisez le nom d'une machine sur la ligne "
"de commande\n");
exit (EXIT_FAILURE);
}
printf ("Nom : %s\n", argv[1]);
if (NULL == (host_entry = gethostbyname (argv[1])))
{
fprintf (stderr, "Échec de gethostbyname()\n");
exit (EXIT_FAILURE);
}
for (i = 0; host_entry->h_addr_list[i]; i++)
printf ("Adresse IP : %s\n",
inet_ntoa (*(struct in_addr *) host_entry->h_addr_list[i])); ...
Créer un serveur TCP/IP multi-processus
Problème
Vous souhaitez créer un serveur TCP/IP qui crée un processus par connexion.
Solution
Créez un serveur comme décrit dans la première recette, mais effectuez un appel à fork() après chaque appel à accept(). La communication avec le client distant s’effectue dans le processus fils pendant que le processus père continue à écouter pour d’autres tentatives de connexion.
Discussion
La création d’un serveur TCP/IP qui génère un processus par connexion est simple car il suffit de reprendre l’exemple de base de la première recette du chapitre et d’y ajouter un appel à fork() juste après accept(). La seule difficulté consiste à lire le code retour des processus fils au bon moment afin d’éviter la création d’un trop grand nombre de processus zombies. Voici un exemple :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/wait.h>
int
create_tcp_server (int port, int nb_max_clients)
{
/* Voir le code dans la recette "Créer un serveur TCP/IP". */
}
int
main...
Créer un serveur TCP/IP multi-thread
Problème
Vous souhaitez créer un serveur TCP/IP qui crée un thread par connexion.
Solution
Créez un serveur comme décrit dans la première recette, mais créez un nouveau thread après chaque appel à accept(). La communication avec le client distant s’effectue dans le thread ainsi créé pendant que le thread principal continue à écouter pour d’autres tentatives de connexion.
Discussion
La programmation d’un serveur TCP/IP multi-thread peut se faire de plusieurs manières. La plus simple consiste à s’inspirer du code d’un serveur TCP/IP multi-processus. Les threads présentent, contrairement aux processus, l’avantage de coûter moins cher au processeur en termes de ressources, et en paramétrant le thread pour qu’il soit détaché, il n’y a pas besoin d’obtenir son code retour, donc pas de balayette à threads zombies à implémenter.
L’exemple ci-dessous imite l’implémentation de la recette précédente en utilisant les threads.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <pthread.h>
int
create_tcp_server (int port, int nb_max_clients)
{
/* Voir le code dans la recette "Créer un serveur TCP/IP". */
}
void *
send_hello (void *userdata)
{
/* Obtention de la socket du client. */
int client_id = *(int *) userdata;
char hello[] = "Bonjour, vous\n";
/* Envoi de données et fermeture de la socket. */
write (client_id, hello, strlen (hello));
shutdown (client_id, 2);
close (client_id);
/* Libération des ressources. */
free (userdata);
return (NULL);
}
int
main ()
{
int socket_id;
/* Création d'un serveur TCP/IP. */
socket_id = create_tcp_server (2000, 10);
while (1)
{
pthread_attr_t *thread_attributes;
pthread_t tid;
int *client_id;
/* Création d'un identifiant de client de la prochaine connexion. */ ...
Créer un serveur TCP/IP mono-processus sans thread
Problème
Vous voulez que votre serveur TCP/IP n’ait qu’une seule file d’exécution.
Solution
Utilisez select() pour détecter les sockets sur lesquelles se trouvent des données à lire, et implémentez vous-même la mise en parallèle de la gestion des connexions.
Discussion
La fonction select() permet de tester entre autres si des données sont disponibles en lecture sur un ou plusieurs descripteurs de fichiers. La boucle principale d’un serveur TCP/IP mono-processus sans thread s’articule donc autour de cette fonction select() afin de déterminer quels clients souhaitent écrire à notre serveur et lesquels il n’est pas nécessaire d’interroger. Voici un exemple :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/time.h>
#include <fcntl.h>
#include <errno.h>
#define INPUTBUFFERBLOCKLEN 1024
#define OUTPUTBUFFERBLOCKLEN 1024
#define NB_CLIENTS_MAX 10
/* Structure contenant les paramètres et données de chaque client. */
typedef struct
{
int fd;
char *buffer;
int buffer_size;
int buffer_len;
int exit;
} client_t;
int
create_tcp_server (int port, int nb_max_clients)
{
/* Voir le code dans la recette "Créer un serveur TCP/IP". */
}
void
client_accept (int socket_id, client_t * client, char *outputbuffer)
{
int socket_options;
if (-1 == (client->fd = accept (socket_id, NULL, 0)))
{
fprintf (stderr, "Erreur sur accept()\n");
exit (EXIT_FAILURE);
}
client->exit = 0;
client->buffer_len = 0;
snprintf (outputbuffer, OUTPUTBUFFERBLOCKLEN,
"Bonjour, vous, quel est votre prénom ?\n");
write (client->fd, outputbuffer, strlen (outputbuffer));
socket_options = fcntl (client->fd, F_GETFL, NULL); ...
Créer un serveur TCP/IP ou UDP/IP qui utilise le démon inetd
Problème
Vous voulez déléguer au super-démon inetd le travail d’écoute sur le port de votre serveur et ne vous charger que du cœur de votre serveur.
Le terme de daemon, inspiré par les démons du physicien Maxwell, est l’acronyme de Disk And Execution MONitor. Nous n’hésitons donc pas à le traduire démon du fait de son origine. Un démon désigne un processus s’exécutant en tâche de fond dans l’attente d’un signal précis ou d’une condition qui se vérifie.
Solution
Créez un programme qui présuppose la connexion établie, avec stdin pour la lecture des données venant du client et stdout pour lui écrire.
Discussion
Le démon inetd est ce qui s’appelle un super-démon, à savoir qu’il écoute sur les ports définis dans son fichier de configuration, généralement /etc/inetd.conf. Lorsque nous cherchons à nous connecter à l’un des ports écoutés par ce super-démon, il exécute le programme associé au port concerné en connectant la socket cliente à stdin pour la lecture des données et stdout pour leur écriture.
Un serveur qui utilise le démon inetd est donc tout simple à écrire...
Résoudre le problème des architectures petit et gros boutistes
Problème
Dans votre environnement hétérogène, vous voulez communiquer à l’aide de fichiers ou de sockets même si les architectures sont différentes.
Solution
Il est préférable de transférer toutes les données sous forme de texte, ce qui permet de contourner élégamment le problème. Sinon, nous pouvons utiliser les fonctions de conversion machine <-> réseau ntohl(), htonl(), ntohs() et htons().
Discussion
Certaines machines stockent les données dans l’ordre où elles apparaissent, c’est-à-dire que 0x11223344 sera traité comme 0x11223344, ce qui est naturel. Les architectures PPC ou Sparc par exemple font comme tel. C’est ce qui s’appelle une architecture petit boutiste (little endian). D’autres architectures, principalement les PC, inversent l’ordre des octets, et traitent 0x11223344 comme 0x33441122. Si vous restez sur une machine, ou que vous échangez des données sur un parc de machines homogène, le problème ne se pose pas car ce qu’une machine code 0x11223344 en 0x33441122 et décode correctement, les autres le décodent de la même manière. Par contre, si une machine d’architecture par exemple gros boutiste (big endian) code 0x11223344 en 0x33441122 et qu’une...
Modifier les options sur une socket
Problème
Vous souhaitez modifier les options sur une socket, tel par exemple, le fait qu’elle devienne non bloquante.
Solution
Utilisez setsockopt() ou fcntl().
Discussion
La fonction setsockopt() permet de spécifier des options à une socket alors que la fonction fcntl() les spécifie au descripteur de fichiers, ce qui s’applique donc aussi à une socket. Si nous voulons rendre une socket non bloquante à la lecture, c’est une propriété du descripteur de fichier et nous utilisons donc fcntl() :
int options;
options = fcntl (socket_id, F_GETFL, 0);
fcntl (socket_id, F_SETFL, options | O_NONBLOCK);
Pour éviter de générer un signal SIGPIPE si une connexion est coupée, nous utilisons setsockopt() :
int optval;
int optlen;
optval = 1;
optlen = sizeof (int);
setsockopt (socket_id, SOL_SOCKET, SO_NOSIGPIPE, &optval, &optlen);
Vous pouvez connaître les options disponibles sur les pages de manuel respectives de fcntl() et surtout setsockopt().
Prototypes
#include <sys/types.h>
#include <sys/socket.h>
int getsockopt (int s, int level, int optname, void *optval,
int *optlen);
int setsockopt (int s, int level, int optname, const void *optval,
int optlen);
#include <fcntl.h>
int fcntl (int fd, int cmd, int arg);
Voir...