Architecture et Fonctionnement d'un Mini-Serveur Web : tinyhttpd

Cet article propose un examen détaillé de l'architecture et des mécanismes fondamentaux du serveur web minimaliste tinyhttpd. Il aborde les étapes clés de son fonctionnement, de l'initialisation du serveur à la gestion des requêtes client, en passant par l'exécution de scripts CGI.

Fonctionnement Principal (main)

La fonction main est le point d'entrée du serveur. Elle orchestre les opérations principales :

  1. Initialisation et écoute des connexions entrantes sur l'adresse IP du serveur.
  2. Boucle infinie acceptant les requêtes des clients. Pour chaque nouvelle connexion, un thread dédié est créé afin de traiter la requête de manière concurrente.
  3. Fermeture ordonnée du serveur (en général lors d'un signal d'arrêt, non montré ici dans la boucle infinie).

#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <pthread.h>
#include <unistd.h> // For close()

// Déclaration anticipée des fonctions
int demarrerServeur(unsigned short *numeroPort);
void *traiterRequeteClient(void *descripteurClient);

int main()
{
    int socketEcoute = demarrerServeur(NULL); // Le port sera alloué dynamiquement si NULL est passé.
    struct sockaddr_in adresseClient;
    socklen_t tailleAdresseClient = sizeof(adresseClient);
    int socketConnexionClient;
    pthread_t idThreadClient;

    if (socketEcoute == -1) {
        perror("Échec du démarrage du serveur");
        return 1;
    }

    printf("Serveur démarré sur le port %u\n", ntohs(adresseClient.sin_port));

    while (1) {
        socketConnexionClient = accept(socketEcoute,
                                       (struct sockaddr *)&adresseClient,
                                       &tailleAdresseClient);

        if (socketConnexionClient == -1) {
            perror("Erreur lors de l'acceptation de la connexion client");
            continue;
        }

        // Création d'un thread pour gérer la requête client
        if (pthread_create(&idThreadClient, NULL, traiterRequeteClient, (void *)(long)socketConnexionClient) != 0) {
            perror("Erreur lors de la création du thread");
            close(socketConnexionClient); // Fermer la socket si le thread ne peut pas être créé
        }
        pthread_detach(idThreadClient); // Détacher le thread pour qu'il libère ses ressources automatiquement
    }

    close(socketEcoute); // Cette ligne ne sera généralement pas atteinte dans une boucle infinie sans gestion de signal.
    return 0;
}

Démarrage du Serveur (demarrerServeur)

La fonction demarrerServeur est chargée de configurer la socket d'écoute du serveur.


#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h> // For close()
#include <string.h> // For memset()

#define NOMBRE_CONNEXIONS_MAX 5 // Nombre maximum de connexions en attente

int demarrerServeur(unsigned short *numeroPort)
{
    int sockServeur = -1;
    struct sockaddr_in infosAdresseServeur;
    int option_val = 1;

    // Création de la socket : AF_INET pour IPv4, SOCK_STREAM pour TCP
    sockServeur = socket(PF_INET, SOCK_STREAM, 0);
    if (sockServeur == -1) {
        perror("Erreur lors de la création de la socket");
        return -1;
    }

    // Autoriser la réutilisation rapide de l'adresse et du port
    if (setsockopt(sockServeur, SOL_SOCKET, SO_REUSEADDR, &option_val, sizeof(option_val)) == -1) {
        perror("Erreur lors de la configuration de SO_REUSEADDR");
        close(sockServeur);
        return -1;
    }

    // Initialisation de la structure d'adresse
    memset(&infosAdresseServeur, 0, sizeof(infosAdresseServeur));
    infosAdresseServeur.sin_family = AF_INET;
    infosAdresseServeur.sin_port = htons(*numeroPort); // Port spécifié ou 0 pour allocation dynamique
    infosAdresseServeur.sin_addr.s_addr = htonl(INADDR_ANY); // Écoute sur toutes les interfaces disponibles

    // Liaison de la socket à l'adresse et au port
    if (bind(sockServeur, (struct sockaddr *)&infosAdresseServeur, sizeof(infosAdresseServeur)) == -1) {
        perror("Erreur lors de la liaison de la socket");
        close(sockServeur);
        return -1;
    }

    // Si le port était 0, récupérer le port alloué par le système
    if (*numeroPort == 0) {
        socklen_t tailleInfosAdresse = sizeof(infosAdresseServeur);
        if (getsockname(sockServeur, (struct sockaddr *)&infosAdresseServeur, &tailleInfosAdresse) == -1) {
            perror("Erreur lors de la récupération du nom de la socket");
            close(sockServeur);
            return -1;
        }
        *numeroPort = ntohs(infosAdresseServeur.sin_port);
    }

    // Mise de la socket en mode écoute
    if (listen(sockServeur, NOMBRE_CONNEXIONS_MAX) == -1) {
        perror("Erreur lors de la mise en écoute de la socket");
        close(sockServeur);
        return -1;
    }

    return sockServeur;
}

Gestion des Requêtes Client (traiterRequeteClient)

La fonction traiterRequeteClient (correspondant à accept_request) est exécutée dans un thread séparé pour chaque connexion client. Sa responsabilité inclut la lecture de la requête HTTP, l'analyse des en-têtes, l'identification de la ressource demandée (fichier statique, script CGI, etc.) et l'appel des fonctions appropriées comme servirFichierStatique (serve_file) ou executerCGI (execute_cgi).

Exécution de Scripts CGI (executerCGI)

La fonction executerCGI gère l'exécution des scripts CGI pour générer du contenu dynamique. Ce processus implique plusieurs concepts clés des systèmes d'exploitation Unix.

Concepts Fondamentaux

1. Création et Remplacement de Processus

Lorsqu'un programme appelle une fonction de la famille exec (par exemple, execl, execv, execve), le processus appelant est entièrement remplacé par le nouveau programme. Ce dernier commence son exécution à partir de sa fonction main. Il est crucial de noter que l'appel à exec ne crée pas un nouveau processus ; il s'agit d'un remplacement de l'image du processus existant. Ainsi, l'identifiant de processus (PID) reste inchangé. L'ancien code, les données, la pile et le tas sont remplacés par ceux du nouveau programme. Les instructions suivant l'appel à exec dans le programme parent ne seront jamais exécutées, même si exec "échoue" (car un échec signifie que le nouveau programme n'a pas pu être chargé, et le processus parent continuerait son exécution normalement après l'échec de exec).

Un point important est que les descripteurs de fichiers ouverts par le processus parent sont conservés par le nouveau programme après un appel exec. Cela est fondamental pour l'interfaçage avec les scripts CGI.

2. Variables d'Environnement

Chaque processus Unix possède un ensemble de variables d'environnement, stockées sous forme de paires "clé=valeur" dans un tableau de chaînes de caractères. Ce tableau est généralement accessible via une variable globale nommée environ. Lors d'un appel exec, ce tableau est copié dans le nouvel espace de données du programme. Par conséquent, un processus enfant (créé par fork puis exec) reçoit une copie de l'environnement de son parent, mais les modifications effectuées par l'enfant n'affecteront pas l'environnement du parent.

  • int putenv(char *envvar); : Ajoute ou modifie une variable d'environnement dans l'environnement courant du processus.
  • char *getenv(const char *name); : Récupère la valeur associée à la variable d'environnement spécifiée.

3. Communication Inter-Processus (IPC)

(1) Redirection de l'Entrée Standard (stdin)

Sous Unix, les descripteurs de fichiers 0, 1 et 2 sont respectivement dédiés à l'entrée standard (stdin), la sortie standard (stdout) et la sortie d'erreur standard (stderr). Lorsqu'un processus demande un nouveau descripteur de fichier, le noyau lui attribue le plus petit descripteur disponible.

Pour rediriger l'entrée standard d'un script CGI vers un fichier ou une autre source de données (par exemple, les données d'une requête POST), plusieurs méthodes peuvent être utilisées :

  • Méthode 1 : Fermer puis Ouvrir ```c

    close(STDIN_FILENO); // STDIN_FILENO est généralement 0 open("nom_du_fichier.txt", O_RDONLY); // Le nouveau fichier obtient le descripteur 0

  • Méthode 2 : Dupliquer un Descripteur (dup) ```c

    int fd_source = open("donnees_post.txt", O_RDONLY); if (fd_source == -1) { /* gérer l'erreur */ }

    close(STDIN_FILENO); // Fermer l'entrée standard actuelle int nouveau_stdin_fd = dup(fd_source); // Duplique fd_source, le plus petit descripteur disponible (ici 0) est utilisé close(fd_source); // Fermer le descripteur original car il n'est plus nécessaire

  • Méthode 3 : Dupliquer vers un Descripteur Spécifique (dup2) ```c

    int fd_source = open("donnees_post.txt", O_RDONLY); if (fd_source == -1) { /* gérer l'erreur */ }

    // dup2 équivaut à un close(STDIN_FILENO) suivi d'un dup(fd_source) si STDIN_FILENO n'est pas déjà fd_source if (dup2(fd_source, STDIN_FILENO) == -1) { /* gérer l'erreur */ } close(fd_source); // Toujours fermer le descripteur original après dup2

    
    
(2) Tuyaux (Pipes)

La fonction pipe() crée un tuyau de communication unidirectionnel entre deux processus (généralement parent et enfant). Elle retourne deux descripteurs de fichiers : tableau_fds[0] pour la lecture et tableau_fds[1] pour l'écriture. Un processus écrit des données à l'extrémité d'écriture (tableau_fds[1]), et un autre processus lit ces données à l'extrémité de lecture (tableau_fds[0]).

4. Scripts CGI

CGI (Common Gateway Interface) est une norme qui définit la manière dont un serveur web peut interagir avec des programmes externes pour générer du contenu dynamique. Un script CGI reçoit des données du client (souvent via l'entrée standard pour les requêtes POST, ou des variables d'environnement pour les requêtes GET) et génère du contenu (généralement HTML) sur sa sortie standard. Le serveur web capture cette sortie standard et la renvoie au client.

Les scripts CGI peuvent être écrits dans n'importe quel langage de programmation capable de lire depuis l'entrée standard et d'écrire vers la sortie standard (par exemple, Python, Perl, C, shell scripts, etc.).

Exemple de formulaire HTML pour illustrer le mécanisme POST/GET :


<form action="/chemin/vers/votre_script_cgi.py" method="post">
    <p>Nom d'utilisateur: <input type="text" name="nomUtilisateur" /></p>
    <p>Mot de passe: <input type="password" name="motDePasse" /></p>
    <input type="submit" value="Se connecter" />
</form>

Lorsqu'un formulaire est soumis avec la méthode POST, les données sont encodées (par exemple, nomUtilisateur=val1&motDePasse=val2) et envoyées dans le corps de la requête HTTP. Le serveur reçoit ces données et les met à la disposition du script CGI via son entrée standrad. Pour une requête GET, les données sont ajoutées à l'URL (par exemple, /chemin/vers/votre_script_cgi.py?nomUtilisateur=val1&motDePasse=val2) et sont rendues disponibles au script CGI via la variable d'environnement QUERY_STRING.

5. Le "Shebang" (#!)

Le "shebang" est une séquence de caractères (#!) placée au début d'un script. En système Unix-like, il spécifie l'interpréteur de commandes qui doit être utilisé pour exécuter le script. Le chemin complet de l'interpréteur suit immédiatement le #!.

Exemples :

  • #!/bin/bash : Exécute le script avec Bash.
  • #!/usr/bin/python3 : Exécute le script avec l'interpréteur Python 3 situé dans /usr/bin.
  • #!/usr/bin/env python3 : Utilise env pour trouver l'interpréteur Python 3 dans le PATH, ce qui rend le script plus portable.

Étiquettes: tinyhttpd Serveur HTTP programmation C sockets CGI

Publié le 18 octobre à 19h44