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.
Aperçu du projet
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" sur0.0.0.0ce 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 comme6667(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).htonsgarantit 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 :
-
prefix(optionnel, ex::nathan!user@host) -
command(ex:JOIN) -
params(unstd::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 :-
Chercher si le channel existe (dans ta
std::vector<Channel>oustd::map). -
Sinon, le créer (
channels.push_back(Channel("#channel"))). -
Y ajouter le
client_fd(et le mettre opérateur s'il est le premier). -
Envoyer au client la
RPL_TOPIC (332)(même s'il n'y en a pas). -
Envoyer au client la
RPL_NAMREPLY (353)(la liste des utilisateurs du channel, ex:@nathan bob). -
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. Letargetpeut être un channel (#chan) ou un user (nathan).-
Si
targetest un channel (#...) : trouver le channel, etsend()le message à tous lesclient_fddu channel (sauf l'expéditeur). -
Si
targetest un user : trouver leclient_fdde ce user (via la map des nicknames) et luisend()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'objetChannelet setter son flaginvite_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