Créer un client Bittorrent en Rust
Table des matières
Aperçu #
Pour mon deuxième exercice d’apprentissage de Rust (après OrganicLox), j’ai décidé de réaliser un client pour Bittorrent, le protocole de transfert de données pair-à-pair. Les clients Bittorrent que j’ai utilisés ont des interfaces graphiques complexes qui semblent destinées aux gens qui téléchargent des torrents à longueur de journée, plutôt que pour l’utilisateur occasionnel comme moi, qui veut tout simplement obtenir un seul fichier. Et comme beaucoup d’applications à interface graphique, les clients Bittorrent ont tendance à accumuler des fichiers qui occupent de l’espace disque dans des répertoires obscurs. Je fais beaucoup de choses dans le terminal et quand j’ai besoin de télécharger un fichier, mon réflexe est d’utiliser curl. Je me suis donc dit, pourquoi ne pas créer quelque chose comme curl pour Bittorrent ? En pensant à l’indicateur de progression de curl, l’idée m’est venue à l’esprit de faire quelque chose de semblable avec Ratatui, une bibliothèque de création d’environnements en mode texte (TUI), que je voulais de toute façon essayer.
La réalisation d’un client Bittorrent semblait être une bonne occasion
d’apprendre la programmation asynchrone en Rust. J’avais lu beaucoup de
critiques d’async en Rust, mais je l’ai trouvé agréable à utiliser (en
utilisant la bibliothèque Tokio) et certainement plus
confortable que les infrastructures logicielles comparables que j’avais
utilisées en Scala (Akka et Cats
Effect). J’aime particulièrement le fait
qu’une fonction async peut être exécutée dans une tâche existante ou dans sa
propre tâche, à la différence du Future de Scala, qui est affecté à un fil
d’exécution et se lance dès sa construction. L’approche de Rust semble mieux
adapté au remaniement et à la maintenabilité. En même temps, je suis content de
ne pas avoir à gérer la complexité liée à l’utilisation des monades comme dans
Cats Effect.
Le résultat est Sayaca, qui porte le nom d’un oiseau sud-américain qui joue un rôle important dans la dispersion des graines (le verbe anglais to seed veut dire ensemencer, et dans la terminologie de Bittorrent, envoyer des données aux autres pairs).
La conception #
Ma première tâche a été de réaliser un analyseur syntaxique pour
Bencode, le format de données
utilisées dans le protocole Bittorrent. Au niveau conceptuel, Bencode ressemble
à JSON, mais il est destiné à la
représentation de données binaires plutôt que textuelles. J’avais envie
d’essayer d’utiliser une bibliothèque de combinateurs d’analyseurs syntaxiques,
et je n’ai pas eu de mal à implémenter l’analyseur en utilisant
nom. Comme je voulais être en mesure de
sérialiser et de désérialiser des structs en Rust au format Bencode, j’ai
ajouté la prise en charge de la bibliothèque Serde. J’ai
publié la crate issue de ce travail sous le nom de
bside.
La principale difficulté dans la conception du client résidait dans le caractère confus des spécifications Bittorrent. Il y a des ambigüités (le mot piece a deux sens différents) et les implémentations se sont écartées des spécifications (par exemple, les identificateurs de pair ne sont pas utilisés pour identifier les pairs). Une grande partie des informations relatives au protocole ne figure pas dans les spécifications officielles, mais dans d’autres documents dispersés sur le Web. Il y a une longue liste (non mise à jour depuis 2018) de projets d’extension du protocole qui sont pris en charge par de nombreux clients. Une extension dite de chiffrement (qui semble en réalité décrire une forme de brouillage) semble être largement mise en œuvre, mais d’après ce que je peux en juger, depuis 2023, sa spécification n’est conservée que dans la Wayback Machine de l’Internet Archive. Il existe un projet de spécification du protocole BitTorrent v2 (non mis à jour depuis 2017) qui tente de corriger certains défauts du protocole d’origine, comme sa dépendance vis-à-vis de la fonction de hachage SHA-1 (qui n’est plus considérée comme sécurisée depuis 2005). J’aurais pu errer longtemps dans un dédale de documents et dans le code source des clients existants. Pour simplifier les choses, j’ai décidé de ne mettre en œuvre que le protocole d’origine et l’extension IPv6 tracker.
Quand un client Bittorrent enregistre des données sur le disque, il devrait
offrir de bonnes performances avec des fichiers volumineux, être capable
d’interrompre un téléchargement et de le reprendre plus tard (ce qui signifie
qu’il doit noter quels fragments ont été téléchargées) et gérer les torrents qui
comprennent plusieurs fichiers. Pour faire tout cela, et pour que les choses
soient claires pour l’utilisateur, j’ai choisi d’enregistrer les données
téléchargées dans un fichier intermédiaire (avec l’extension .sayaca), dans le
même répertoire où les fichiers finaux seront enregistrés. Le fichier
intermédiaire contient un champ de bits indiquant les fragments qui ont été
téléchargés (il s’agit du même champ de bits qu’il faut de toute façon envoyer
aux pairs). Pour lire et écrire ce fichier, Sayaca utilise la projection en
mémoire, ce qui devrait offrir de bonnes performances d’entrée-sortie avec des
fichiers volumineux. Une fois le téléchargement terminé, il copie les données
depuis le fichier intermédiaire vers les fichiers finaux. Lorsque le programme
se ferme, le fichier intermédiaire est supprimé s’il n’est plus nécessaire.
Comme un client Bittorrent est tributaire des entrées-sorties (I/O bound), j’ai décidé d’employer une architecture événementielle dans laquelle une seule tâche accomplit la majeure partie du travail, comme dans Redis. Cette tâche principale délègue toute les entrées-sorties réseau à d’autres tâches, avec lesquelles elle communique via des canaux. Cela a été très simple à mettre en œuvre et a eu l’avantage de rendre facile la création de tests unitaires pour la tâche principale.
Une grande partie du travail effectué par un client Bittorrent revient à tenir
une sorte de comptabilité. Dans Sayaca, ce travail est la responsabilité du
Bookkeeper
(« comptable »). Par exemple, dans l’algorithme
recommandé
de sélection des fragments à demander aux pairs, le client essaie de demander
d’abord les fragments les plus rares. Cela nécessite une structure de données
qui note les fragments dont dispose chaque pair, ainsi que la rareté de chaque
fragment, entre autres. Il y a plusieurs façons de réaliser cela et j’ai décidé
d’utiliser un tableau associatif
ordonné, trié
par ordre de rareté, pour stocker les identificateurs de fragments et les
ensembles de pairs qui les possèdent. Le Bookkeeper peut ainsi trouver
facilement un fragment, choisi au hasard parmi les plus rares, associé à un pair
choisi au hasard parmi ceux qui possèdent ce fragment et qui nous ont autorisé à
leur envoyer des demandes. Ainsi une grande partie du travail du client consiste
à mettre à jour le Bookkeper lors de la réception de nouvelles informations et
à l’interroger pour savoir ce qu’il faut faire ensuite.
J’aime bien les TUI et il a été facile de créer un TUI simple avec Ratatui. Mais comme le client est une bibliothèque qui communique avec son interface utilisateur via des canaux, il serait simple d’intégrer le client dans une autre interface utilisateur, comme une interface en ligne de commande plus accessible.
D’autres approches possibles #
J’ai choisi d’utiliser Tokio parce que je voulais en savoir plus à son sujet et
sur async, mais je pense que le programme n’aurait probablement pas été très
différent si j’avais utilisé des fils d’exécution à la place, avec la même
communication via des canaux.