IRC

Un projet d’école 42 consistant à développer un serveur IRC (Internet Relay Chat) minimaliste en C++. L’objectif est de comprendre le modèle client-serveur, la gestion des sockets et la communication en réseau.

C C++ 42 Socket

Aperçu du projet

Démo de IRC

ft_irc

Un projet de l'école 42 consistant à développer un serveur IRC (Internet Relay Chat) minimaliste en C++. L’objectif est de comprendre le modèle client-serveur, la gestion des sockets, la communication en réseau, et surtout, la gestion de connexions multiples (multiplexing).

Le serveur ft_irc implémente un sous-ensemble du protocole IRC. On ne parle pas juste d'envoyer "salut", mais de gérer des connexions, une authentification (PASS, NICK, USER), des canaux (JOIN, PART, MODE), des messages privés (PRIVMSG) et l'état des utilisateurs. Chaque client se connecte, s'identifie, et peut ensuite discuter en temps réel.

Concepts clés

Qu’est-ce qu’un socket ?

Un socket est un "point de terminaison" pour la communication. C'est la porte d'entrée de ton application sur le réseau. Dans ce projet, on l'utilise en mode TCP/IP, ce qui veut dire qu'il est identifié par une paire unique : (adresse IP : port).

Adresse et port

  • Adresse : Identifie la machine sur le réseau. On utilise souvent l'IPv4 (e.g. 192.168.0.42). Dans le code du serveur, on "bind" sur 0.0.0.0 ce qui signifie "accepter les connexions venant de n'importe quelle interface réseau de cette machine".

  • Port : Identifie le service ou l'application sur cette machine (un entier de 0 à 65535).

    • Ports 0–1023 : Réservés (HTTP: 80, SSH: 22). Il faut être root pour s'y lier.

    • Ports 1024+ : Utilisables librement. Pour ft_irc, on prend un port comme 6667 (le port IRC traditionnel non sécurisé) ou un autre.

Implémentation

1. Création

On demande au système d'exploitation de nous créer un "descripteur de fichier" (un int) qui servira de socket.

// AF_INET: On veut de l'IPv4.
// SOCK_STREAM: On veut du TCP.
// 0: Protocole par défaut (TCP pour SOCK_STREAM).
int sock_listen = socket(AF_INET, SOCK_STREAM, 0);
if (sock_listen < 0) {
    // Si ça rate, on ne peut rien faire.
    std::cerr << "Erreur : échec de la création du socket\n";
    return EXIT_FAILURE;
}

2. Configuration

On prépare une structure sockaddr_in pour dire au système sur quel port et quelle adresse on veut écouter.

sockaddr_in addr;
addr.sin_family = AF_INET; // On confirme : IPv4
addr.sin_port = htons(8080); // Le port. htons() gère le "byte order" (Big Endian)
inet_pton(AF_INET, "0.0.0.0", &addr.sin_addr); // On écoute sur toutes les interfaces
  • htons() (Host to Network Short) : Les ordis ne stockent pas les chiffres pareil (https://en.wikipedia.org/wiki/Endianness). htons garantit que le numéro de port est dans le format standard du réseau. C'est obligatoire.

  • inet_pton() : Convertit une IP en format texte (comme "0.0.0.0") en son équivalent binaire dans la structure.

3. Bind

C'est l'action de raccrocher notre socket (sock_listen) à la configuration IP/Port qu'on vient de préparer. C'est le moment où on dit : "Ce socket (int) est maintenant responsable du port 8080."

if (bind(sock_listen, (sockaddr*)&addr, sizeof(addr)) < 0) {
    std::cerr << "Erreur : échec du bind (le port est peut-être déjà pris)\n";
    return EXIT_FAILURE;
}

4. Listen

Notre socket est lié. Maintenant, on le bascule en mode "serveur". Il n'est plus fait pour initier des connexions, mais pour les accepter.

// SOMAXCONN: Taille de la file d'attente pour les connexions
if (listen(sock_listen, SOMAXCONN) < 0) {
    std::cerr << "Erreur : échec de l’écoute\n";
    return EXIT_FAILURE;
}

Gérer plusieurs clients

On ne peut pas juste appeler accept() et attendre.
accept() est bloquant. Si on l'appelle, le serveur est figé et attend un client. Il ne peut pas gérer les messages des clients déjà connectés.

La solution est le multiplexing d'I/O. On va surveiller tous nos sockets (celui d'écoute + tous ceux des clients) en même temps. Pour ft_irc, l'outil classique est select(). (Meme si en vrai poll() est mieux mais j'avais la flemme et pas le temps)

5. Initialiser select()

select() a besoin de savoir quels sockets surveiller. On utilise un fd_set, qui est en gros un tableau de bits.

fd_set master_set; // La liste permanente de TOUS les sockets (serveur + clients)
fd_set read_fds;   // Une copie temporaire que select() va modifier
int fdmax;         // Le numéro du plus grand socket (pour optimiser la boucle)

FD_ZERO(&master_set); // On vide la liste
FD_ZERO(&read_fds);

// On ajoute le seul socket qu'on a pour l'instant : celui qui écoute
FD_SET(sock_listen, &master_set);
fdmax = sock_listen; // Pour l'instant, c'est lui le max

std::cout << "Serveur en attente de connexions..." << std::endl;

6. La Boucle Principale (Event Loop)

C'est la boucle while (true) qui est ton serveur. Elle va tourner indéfiniment.

while (true) {
    // select() est destructif : il modifie le 'read_fds' qu'on lui donne.
    // On lui passe donc une copie de notre liste 'master' à chaque tour.
    read_fds = master_set;

    // C'est ici que le serveur "dort" et attend que quelque chose se passe.
    // Il attend qu'un des sockets dans 'read_fds' devienne "prêt à lire".
    if (select(fdmax + 1, &read_fds, NULL, NULL, NULL) == -1) {
        std::cerr << "Erreur select()" << std::endl;
        break;
    }

    // Si on arrive ici, c'est que select() s'est réveillé
    // Au moins un socket a bougé. On doit trouver lequel.
    // On parcourt tous les sockets possibles, de 0 jusqu'au plus grand (fdmax).
    for (int i = 0; i <= fdmax; i++) {

        // FD_ISSET : Est-ce que le socket 'i' est dans la liste
        // des sockets qui sont "prêts" ?
        if (FD_ISSET(i, &read_fds)) {

            // Si le socket prêt, c'est notre socket d'écoute...
            if (i == sock_listen) {
                sockaddr_in client_addr;
                socklen_t len = sizeof(client_addr);
                int sock_client = accept(sock_listen, (sockaddr*)&client_addr, &len);

                if (sock_client < 0) {
                    std::cerr << "Erreur accept()" << std::endl;
                } else {
                    // On l'ajoute à notre liste 'master' pour le surveiller
                    FD_SET(sock_client, &master_set); 
                    if (sock_client > fdmax)
                        fdmax = sock_client; // On met à jour le max si besoin

                    std::cout << "Nouvelle connexion (fd=" << sock_client << ")" << std::endl;

                    // C'est ici qu'on initialise ce client dans nos maps C++
                    // Ex: client_authenticated[sock_client] = false;
                    // Ex: client_buffer[sock_client] = "";
                }
            } 

            else {
                // Le socket 'i' est un client qui nous envoie un message.
                // On passe la main à une fonction qui gère la réception.
                handleClientData(i, master_set, fdmax);
            }
        }
    }
}

7. Buffering

On ne lit jamais un message IRC complet avec recv(). TCP est un protocole de flux (stream), pas de message. Le réseau peut couper un message en deux, ou coller deux messages ensemble.

On peut recevoir NICK nat... puis 2 secondes plus tard... han\r\nUSER.... Ou on peut recevoir NICK nathan\r\nUSER nathan 0 * :Nathan\r\n d'un seul coup.

Chaque client doit avoir son propre buffer (une std::map<int, std::string> client_buffer) qui sert de "salle d'attente" pour les données.

void handleClientData(int client_fd, fd_set& master_set, int& fdmax) {
    char buffer[4096]; // Un buffer temporaire pour ce recv()
    int bytes = recv(client_fd, buffer, sizeof(buffer), 0);

    // Si recv() renvoie 0, le client a fermé la connexion (Ctrl+C).
    // Si recv() renvoie -1, c'est une erreur.
    if (bytes <= 0) {
        if (bytes == 0)
            std::cout << "Client (fd=" << client_fd << ") déconnecté." << std::endl;
        else
            std::cerr << "Erreur recv()" << std::endl;

        close(client_fd); // On ferme son socket
        FD_CLR(client_fd, &master_set);

        // C'est ici qu'on appelle la logique de QUIT
    }

    else {
        // On ajoute les données brutes au buffer PERSISTANT de CE client
        client_buffer[client_fd].append(buffer, bytes);

        // On traite le buffer. On cherche des commandes complètes
        //    (terminées par \r\n) TANT QU'IL Y EN A.
        size_t eol_pos;
        while ((eol_pos = client_buffer[client_fd].find("\r\n")) != std::string::npos) {

            // On extrait la commande complète
            std::string command = client_buffer[client_fd].substr(0, eol_pos);

            // On la SUPPRIME du buffer (très important)
            //     On garde le reste (ex: une moitié de commande suivante)
            client_buffer[client_fd].erase(0, eol_pos + 2); // +2 pour virer \r\n

            // On la traite ENFIN !
            if (!command.empty()) {
                handleClientMessage(client_fd, command);
            }
            // La boucle 'while' recommence direct, pour voir s'il y avait
            // une 2ème commande collée (ex: "NICK...\r\nUSER...\r\n")
        }
    }
}

8. Parser les Commandes

Maintenant, on a une command clean (ex: JOIN #test). Il faut la parser.

Idéalement, on code un vrai parser qui découpe la commande en :

  1. prefix (optionnel, ex: :nathan!user@host)

  2. command (ex: JOIN)

  3. params (un std::vector<std::string>, ex: ["#test"])

C'est propre, robuste, mais ça prend du temps.

Par contrainte de temps sur ft_irc, beaucoup optent (fin surtout moi) pour une méthode plus "directe" à base de find(" ") et substr(). C'est fragile (ça casse si y'a trop d'espaces, ou pas d'espaces), mais ça passe pour les commandes simples.

void handleClientMessage(int client_fd, const std::string& message) {
    std::cout << "Reçu de (fd=" << client_fd << "): " << message << std::endl;

    // On check si la ligne COMMENCE par "NICK "
    if (message.rfind("NICK ", 0) == 0) {
        // handleNick(client_fd, message);
    } 
    else if (message.rfind("USER ", 0) == 0) {
        // handleUser(client_fd, message);
    }
    else if (message.rfind("PASS ", 0) == 0) {
        // handlePass(client_fd, message);
    }
    else if (message.rfind("JOIN ", 0) == 0) {
        // handleJoin(client_fd, message);
    }
    // ... etc
}

Note : Cette méthode rfind est fragile car elle ne gère pas NICK tout seul, ou plusieurs espaces.

9. Parler le VRAI IRC

Ton serveur ne peut pas juste envoyer "OK" ou "Bienvenue". Un client IRC (HexChat, Irssi) est bête : il attend des réponses standardisées (RFC 2812), avec des codes numériques.

La plus importante : RPL_WELCOME (001).

Tant qu'un client n'a pas reçu la reply 001, il considère qu'il n'est pas connecté. Il n'affichera rien, n'enverra rien (sauf PING). L'authentification PASS/NICK/USER n'est validée que par l'envoi de ce 001.

Il faut absolument une fonction helper pour formater ces messages :

// Format: :<nom_serveur> <CODE> <nick> :<message>
void sendReply(int client_fd, const std::string& code, const std::string& nickname, const std::string& message) {
    // Ex: ":irc.nathaan.me 001 nathan :Welcome to the ft_irc server!\r\n"
    std::string reply = ":" + SERVER_NAME + " " + code + " " + nickname + " :" + message + "\r\n";

    // On envoie la réponse au client
    if (send(client_fd, reply.c_str(), reply.length(), 0) < 0) {
        std::cerr << "Erreur send()" << std::endl;
    }
}

// ...
// Et dans la logique, APRÈS que NICK, USER et PASS soient validés :
// sendReply(client_fd, "001", "nathan", "Welcome to the ft_irc server!");
// sendReply(client_fd, "002", "nathan", "Your host is ..., running version X");
// sendReply(client_fd, "003", "nathan", "This server was created ...");
// sendReply(client_fd, "004", "nathan", "...");

10. Commandes Clés (Ex: MODE simple)

Une fois la base en place, gérer les commandes devient répétitif :

  • JOIN : Le client envoie JOIN #channel. Le serveur doit :

    1. Chercher si le channel existe (dans ta std::vector<Channel> ou std::map).

    2. Sinon, le créer (channels.push_back(Channel("#channel"))).

    3. Y ajouter le client_fd (et le mettre opérateur s'il est le premier).

    4. Envoyer au client la RPL_TOPIC (332) (même s'il n'y en a pas).

    5. Envoyer au client la RPL_NAMREPLY (353) (la liste des utilisateurs du channel, ex: @nathan bob).

    6. Envoyer un JOIN (préfixé par le client) à tous les autres membres du channel pour les prévenir.

  • PRIVMSG : Le client envoie PRIVMSG <target> :message. Le target peut être un channel (#chan) ou un user (nathan).

    1. Si target est un channel (#...) : trouver le channel, et send() le message à tous les client_fd du channel (sauf l'expéditeur).

    2. Si target est un user : trouver le client_fd de ce user (via la map des nicknames) et lui send() le message.

  • MODE : C'est la commande la plus chiante. Pour ft_irc, on se contente souvent des modes de base.

    • Ex: MODE #channel +i (passe le channel en "invite-only").

    • Le serveur doit parser le +i, trouver l'objet Channel et setter son flag invite_only à true, puis informer tout le channel du changement de mode.

Bon apres y'en a pleins d'autres mais je conseille plus de lire le man de la norme RFC 2812
https://modern.ircdocs.horse/

Bon courage pour la suite !

Publié en May 2025