Résilience et auto-réparation

Un réseau décentralisé n'est durable qu'à hauteur de sa capacité à se relever de défaillances partielles. Des serveurs passent hors ligne. De nouveaux serveurs rejoignent et doivent amorcer leurs données. Des partitions réseau provoquent des divergences. Hashiverse traite cela par des protocoles d'auto-réparation qui opèrent sans aucun coordinateur central — pilotés par les clients et les serveurs détectant l'incohérence et la corrigeant.

Sharding et répartition de charge

Les médias sociaux suivent des lois de puissance. Un petit nombre d'utilisateurs et de hashtags attire la grande majorité des abonnés et génère la majorité des publications. Dans une DHT naïve, cela signifie qu'un petit nombre de nœuds porterait la majorité de la charge — devenant des goulots de performance et des cibles DDoS de grande valeur du seul fait du contenu qu'ils hébergent. Hashiverse traite cela au niveau du modèle de données par un sharding basé sur le temps et la fréquence.

Clés de bucket par époque

Le contenu d'un utilisateur ou d'un hashtag n'est pas stocké sous une clé DHT unique et figée. La clé est fonction de l'identifiant public de l'utilisateur (ou du texte du hashtag) et d'une époque temporelle. L'emplacement DHT des publications d'un utilisateur se déplace donc dans le temps, étalant la responsabilité d'héberger son contenu sur différents nœuds dans différentes époques.

Pour les utilisateurs et hashtags à fort volume, l'espace des clés est encore subdivisé : plus une identité est active en volume de publications, plus la subdivision en buckets devient granulaire. Le contenu d'un posteur prolifique est réparti sur davantage de buckets, plus petits — chacun hébergé par une région différente de l'anneau DHT. Cela est capturé par le type BucketLocation, qui encode ensemble l'identité, l'époque et la granularité du bucket.

Le résultat : aucun nœud ne devient durablement responsable du trafic d'un utilisateur à forte audience, et aucun nœud ne devient une cible stable pour une attaque DDoS visant à faire taire un compte ou un hashtag précis.

Source : buckets.rsBucketLocation

Mise en cache pilotée par la demande

Au-delà de la réparation (qui répare les données manquantes après coup), Hashiverse implémente une mise en cache pilotée par la demande pour réduire la charge sur les serveurs DHT les plus proches pour les buckets populaires. Des serveurs intermédiaires mettent en cache les bundles de publications et les bundles de retours pour le compte des nœuds les plus proches du contenu, servant les clients directement et terminant le parcours Kademlia plus tôt. Le cache s'étend vers l'extérieur sous une demande soutenue et se contracte automatiquement quand l'intérêt baisse — entièrement auto-régulé, aucune coordination requise.

Mécanisme de jeton à seuil de hits

Chaque serveur maintient un cache de bundles et un cache de retours (un cache pondéré Moka par type). À chaque requête entrante GetPostBundleV1 ou GetPostBundleFeedbackV1, le serveur incrémente un compteur de hits pour cet emplacement de bucket. Une fois que le compteur atteint le seuil (actuellement 10 hits dans la fenêtre d'inactivité), le serveur émet un CacheRequestTokenV1 — un jeton de courte durée signé par le serveur et portée à cet emplacement de bucket.

Le client collecte tous les jetons émis pendant son parcours Kademlia. Après avoir récupéré et vérifié avec succès le bundle depuis un serveur responsable, il téléverse de manière asynchrone le bundle vers chaque serveur émetteur de jeton via CachePostBundleV1 / CachePostBundleFeedbackV1. Le serveur valide le jeton (signature, expiration, correspondance d'emplacement), parse le bundle et le stocke dans le cache en mémoire. Les clients suivants qui passent par ce serveur reçoivent le bundle en cache directement, sans poursuivre le parcours plus loin.

Un cache de déduplication en cours (clé par identifiant d'emplacement, TTL correspondant à la durée de vie du jeton) empêche le serveur d'émettre des jetons en double pour plusieurs parcoureurs concurrents sur le même bucket.

Source : post_bundle_caching.rs, post_bundle_feedback_caching.rs

Buckets vivants vs scellés

Le cache distingue les données vivantes et scellées :

Rayon de découverte côté client

Pour tirer parti du cache en expansion, chaque client maintient un rayon de découverte par bucket : la distance XOR jusqu'au serveur le plus éloigné qui a renvoyé un bundle avec succès lors du parcours précédent. Au parcours suivant, le PeerIterator saute les serveurs plus proches que ce rayon et commence à interroger depuis la frontière vers l'extérieur — allant directement là où le cache a été trouvé en dernier, contournant entièrement les serveurs responsables (les plus intérieurs).

Le rayon est mis à jour après chaque parcours à la distance XOR du répondant positif le plus éloigné. Si les couches externes du cache ont expiré entre-temps et qu'aucun serveur à la frontière ne répond, le parcours retombe sur le pair le plus proche suivant et le rayon se contracte naturellement pour refléter la profondeur courante du cache. Le rayon est conservé dans un cache à time-to-idle, de sorte que les buckets non visités depuis un moment repartent depuis les serveurs responsables au prochain accès.

Éviction et capacité du cache

Les deux caches sont pondérés par la taille effective des bundles en octets, adossés à la politique d'admission W-TinyLFU de Moka. Les entrées « placeholder » (emplacements qui ont été interrogés mais pas encore peuplés d'un bundle) utilisent un petit poids fixe afin de participer à l'esquisse de fréquence sans consommer de capacité réelle. Quand la limite haute en octets est atteinte, les emplacements les moins fréquemment utilisés sont évincés en premier. Chaque emplacement a aussi un time-to-idle de 60 secondes : un emplacement qui cesse de recevoir des requêtes est évincé en silence, réinitialisant son compteur de hits et contractant le rayon effectif du cache.

Le cache de bundles stocke jusqu'à un nombre configurable d'origines par emplacement (actuellement 5). Si une nouvelle origine arrive alors que le plafond par emplacement est plein, le bundle qui expire le plus tôt est remplacé. Les bundles scellés (sans expiration) sont traités comme expirant en dernier et ne sont déplacés que par d'autres bundles scellés.

Source : post_bundle_caching_shared.rs, config.rsSERVER_POST_BUNDLE_CACHE_MAX_BYTES et autres.

Réparation des bundles de publications

Quand un client récupère des publications pour un bucket, il interroge plusieurs serveurs et collecte leurs réponses. Il compare ensuite : quel serveur a des publications qu'un autre serveur n'a pas ? Pour chaque paire donneur-cible où le donneur a des publications que la cible n'a pas, le client organise une réparation en deux phases :

  1. Phase de claim : le client demande au serveur cible lesquelles des publications du donneur il lui faut, via une requête HealPostBundleClaimV1. Le serveur déclare les identifiants de publications qui lui manquent.
  2. Phase de commit : le client (ou le donneur, agissant comme proxy) envoie ces octets de publications spécifiques à la cible via HealPostBundleCommitV1.

Cela tourne dans des tâches d'arrière-plan démarrées après que la récupération principale du client est terminée. L'utilisateur voit son contenu sans attendre la réparation ; le réseau se répare en parallèle.

Source : post_bundle_healing.rs, test_healing_post_bundles.rs

Réparation des retours

Les signaux de retour — j'aime, signalements, je n'aime pas — divergent aussi entre serveurs à mesure qu'ils se propagent dans le réseau. Un signalement peut atteindre le serveur A mais pas le serveur B. La réparation des retours traite cela séparément de la réparation des publications, parce que les retours ont une forme de données différente (l'enregistrement EncodedPostFeedbackV1 de 50 octets) et une règle de fusion différente.

La règle de fusion pour les retours est : pour chaque paire (post_id, type_de_retour), garder le signal avec la PoW la plus élevée parmi tous les serveurs. Le client, après avoir collecté des retours depuis plusieurs serveurs, calcule le maximum global et identifie les serveurs qui ont des signaux plus faibles que ce maximum. Il envoie alors les signaux plus forts à ces serveurs via HealPostBundleFeedbackV1.

C'est une réparation en une seule phase (pas de phase de claim) : l'enregistrement de retour est suffisamment petit pour qu'envoyer inconditionnellement coûte moins cher que l'aller-retour consistant à demander d'abord.

Source : post_bundle_feedback_healing.rs, test_healing_post_bundle_feedbacks.rs

Résistance aux DDoS

Hashiverse a quatre couches de protection DDoS qui interceptent les attaques à des points progressivement plus précoces du cycle de vie d'une connexion, du filtrage de paquets au niveau du noyau jusqu'au limiteur de débit applicatif.

Couche noyau : liste noire ipset

Pour les attaques soutenues ou sévères, le serveur escalade vers l'abandon de paquets au niveau noyau via l'ipset Linux. Les IPs sur liste noire sont ajoutées à un ensemble de hachage avec un timeout de cinq minutes ; iptables (ou nftables) abandonne les paquets correspondants avant qu'ils n'atteignent la couche applicative, éliminant même le coût d'accepter et de rejeter la connexion TCP.

ipset create hashiverse_ddos_blacklist hash:ip timeout 300

Le serveur Rust ajoute des IPs à cet ensemble via des appels non bloquants Command::new("ipset") — l'opération noyau ne bloque pas le traitement des requêtes. Cela exige CAP_NET_ADMIN ou root en production, géré via les Linux Ambient Capabilities.

Pré-TLS : plafond de connexions et limite de slots par IP

Chaque nouvelle connexion TCP est vérifiée avant que la poignée de main TLS ne commence. Deux gardes s'exécutent à ce moment :

Les deux vérifications se déclenchent avant l'appel à TlsAcceptor::accept, de sorte qu'une avalanche de nouvelles connexions depuis une seule IP ne consomme aucun CPU pour la négociation TLS. Le serveur est délibérément IPv4 uniquement pour l'instant ; la prise en charge d'IPv6 nécessite un suivi par préfixe (/64) pour être efficace, ce qui n'est pas encore implémenté.

Timeout de poignée de main TLS

Une fois qu'une connexion passe les gardes pré-TLS, la poignée de main TLS est encadrée par un timeout strict. Un client qui envoie un ClientHello mais ne complète jamais la poignée de main — une connexion lente classique au niveau TLS — est largué après un nombre fixe de secondes et son score de mauvaise requête est incrémenté, le rapprochant du bannissement applicatif.

Défense Slow Loris : timeouts de lecture d'en-têtes et de corps

Après une poignée de main TLS réussie, deux autres timeouts protègent contre les attaques de maintien de connexion au niveau HTTP :

Couche applicative : cache de réputation IP

Les requêtes qui passent toutes les couches précédentes sont suivies par adresse IP via un cache en mémoire Moka. Les IPs qui dépassent le seuil de débit de requêtes dans une fenêtre glissante reçoivent une réponse d'erreur RPC Hashiverse et accumulent un score de mauvaise requête. Une fois que le score franchit un seuil, l'IP est promue sur la liste noire ipset, la faisant tomber sur la couche noyau pour les futures connexions.

Source : hashiverse-server-lib/src/network/transport/, config.rs

Abstraction TimeProvider

Le trait TimeProvider abstrait sur le temps réel, permettant ScaledTimeProvider — une horloge de simulation à temps accéléré utilisée en tests. Les tests d'intégration qui vérifient le comportement de réparation sur des fenêtres de temps qui prendraient normalement des heures peuvent tourner en quelques secondes en mettant le temps à l'échelle. C'est ainsi que la suite de tests d'intégration peut vérifier le cycle de vie complet de la réparation — y compris l'expiration des anciens contenus — sans délais d'horloge murale.

Source : hashiverse-lib/src/tools/time_provider/