Files
oc-discovery/ARCHITECTURE.md
2026-03-03 16:38:24 +01:00

24 KiB
Raw Blame History

oc-discovery — Architecture et analyse technique

Convention de lecture Les points marqués ont été corrigés dans le code. Les points marqués ⚠️ restent ouverts.

Table des matières

  1. Vue d'ensemble
  2. Hiérarchie des rôles
  3. Mécanismes principaux
    • 3.1 Heartbeat long-lived (node → indexer)
    • 3.2 Scoring de confiance
    • 3.3 Enregistrement auprès des natifs (indexer → native)
    • 3.4 Pool d'indexeurs : fetch + consensus
    • 3.5 Self-delegation et offload loop
    • 3.6 Résilience du mesh natif
    • 3.7 DHT partagée
    • 3.8 PubSub gossip (indexer registry)
    • 3.9 Streams applicatifs (node ↔ node)
  4. Tableau récapitulatif
  5. Risques et limites globaux
  6. Pistes d'amélioration

1. Vue d'ensemble

oc-discovery est un service de découverte P2P pour le réseau OpenCloud. Il repose sur libp2p (transport TCP + PSK réseau privé) et une DHT Kademlia (préfixe oc) pour indexer les pairs. L'architecture est intentionnellement hiérarchique : des natifs stables servent de hubs autoritaires auxquels des indexeurs s'enregistrent, et des nœuds ordinaires découvrent des indexeurs via ces natifs.

     ┌──────────────┐       heartbeat       ┌──────────────────┐
     │   Node       │ ───────────────────►  │    Indexer       │
     │  (libp2p)    │ ◄─────────────────── │   (DHT server)   │
     └──────────────┘    stream applicatif  └────────┬─────────┘
                                                     │ subscribe / heartbeat
                                                     ▼
                                            ┌──────────────────┐
                                            │  Native Indexer  │◄──► autres natifs
                                            │  (hub autoritaire│     (mesh)
                                            └──────────────────┘

Tous les participants partagent une clé pré-partagée (PSK) qui isole le réseau des connexions libp2p externes non autorisées.


2. Hiérarchie des rôles

Rôle Binaire Responsabilité
Node node_mode=node Se fait indexer, publie/consulte des records DHT
Indexer node_mode=indexer Reçoit les heartbeats, écrit en DHT, s'enregistre auprès des natifs
Native Indexer node_mode=native Hub : tient le registre des indexeurs vivants, évalue le consensus, sert de fallback

Un même processus peut cumuler les rôles node+indexer ou indexer+native.


3. Mécanismes principaux

3.1 Heartbeat long-lived (node → indexer)

Fonctionnement

Un stream libp2p persistant (/opencloud/heartbeat/1.0) est ouvert depuis le nœud vers chaque indexeur de son pool (StaticIndexers). Toutes les 20 secondes, le nœud envoie un Heartbeat JSON sur ce stream. L'indexeur répond en enregistrant le peer dans StreamRecords[ProtocolHeartbeat] avec une expiry de 2 min.

Si sendHeartbeat échoue (stream reset, EOF, timeout), le peer est retiré de StaticIndexers et replenishIndexersFromNative est déclenché.

Avantages

  • Détection rapide de déconnexion (erreur sur le prochain encode).
  • Un seul stream par pair réduit la pression sur les connexions TCP.
  • Le channel de nudge (indexerHeartbeatNudge) permet un reconnect immédiat sans attendre le ticker de 20 s.

Limites / risques

  • ⚠️ Un seul stream persistant : si la couche TCP reste ouverte mais "gelée" (middlebox, NAT silencieux), l'erreur peut ne pas remonter avant plusieurs minutes.
  • ⚠️ StaticIndexers est une map partagée globale : si deux goroutines appellent replenishIndexersFromNative simultanément (cas de perte multiple), on peut avoir des écritures concurrentes non protégées hors des sections critiques.

3.2 Scoring de confiance

Fonctionnement

Avant d'enregistrer un heartbeat dans StreamRecords, l'indexeur vérifie un score minimum calculé par CheckHeartbeat :

Score = (0.4 × uptime_ratio + 0.4 × bpms + 0.2 × diversity) × 100
  • uptime_ratio : durée de présence du peer / durée depuis le démarrage de l'indexeur.
  • bpms : débit mesuré via un stream dédié (/opencloud/probe/1.0) normalisé par 50 Mbps.
  • diversity : ratio d'IP /24 distincts parmi les indexeurs que le peer déclare.

Deux seuils sont appliqués selon l'état du peer :

  • Premier heartbeat (peer absent de StreamRecords, uptime = 0) : seuil à 40.
  • Heartbeats suivants (uptime accumulé) : seuil à 75.

Avantages

  • Décourage les peers éphémères ou lents d'encombrer le registre.
  • La diversité réseau réduit le risque de concentration sur un seul sous-réseau.
  • Le stream de probe dédié évite de polluer le stream JSON heartbeat avec des données binaires.
  • Le double seuil permet aux nouveaux peers d'être admis dès leur première connexion.

Limites / risques

  • Deadlock logique de démarrage corrigé : avec uptime = 0 le score maximal était 60, en-dessous du seuil de 75. Les nouveaux peers étaient silencieusement rejetés à jamais. → Seuil abaissé à 40 pour le premier heartbeat (isFirstHeartbeat), 75 ensuite.
  • ⚠️ Les seuils (40 / 75) restent câblés en dur, sans possibilité de configuration.
  • ⚠️ La mesure de bande passante envoie entre 512 et 2048 octets par heartbeat : à 20 s d'intervalle et 500 nœuds max, cela représente ~50 KB/s de trafic probe en continu.
  • ⚠️ diversity est calculé sur les adresses que le nœud déclare avoir — ce champ est auto-rapporté et non vérifié, facilement falsifiable.

3.3 Enregistrement auprès des natifs (indexer → native)

Fonctionnement

Chaque indexeur (non-natif) envoie périodiquement (toutes les 60 s) une IndexerRegistration JSON sur un stream one-shot (/opencloud/native/subscribe/1.0) vers chaque natif configuré. Le natif :

  1. Stocke l'entrée en cache local avec un TTL de 90 s (IndexerTTL).
  2. Gossipe le PeerID sur le topic PubSub oc-indexer-registry aux autres natifs.
  3. Persiste l'entrée en DHT de manière asynchrone (retry jusqu'à succès).

Avantages

  • Stream jetable : pas de ressource longue durée côté natif pour les enregistrements.
  • Le cache local est immédiatement disponible pour handleNativeGetIndexers sans attendre la DHT.
  • La dissémination PubSub permet à d'autres natifs de connaître l'indexeur sans qu'il ait besoin de s'y enregistrer directement.

Limites / risques

  • TTL trop serré corrigé : le TTL de 66 s n'était que 10 % au-dessus de l'intervalle de 60 s — un léger retard réseau pouvait expirer un indexeur sain entre deux renewals. → IndexerTTL porté à 90 s (+50 %).
  • ⚠️ Si le PutValue DHT échoue définitivement (réseau partitionné), le natif possède l'entrée mais les autres natifs qui n'ont pas reçu le message PubSub ne la connaissent jamais — incohérence silencieuse.
  • ⚠️ RegisterWithNative ignore les adresses en 127.0.0.1, mais ne gère pas les adresses privées (RFC1918) qui seraient non routables depuis d'autres hôtes.

3.4 Pool d'indexeurs : fetch + consensus

Fonctionnement

Lors de ConnectToNatives (démarrage ou replenish), le nœud/indexeur :

  1. Fetch : envoie GetIndexersRequest au premier natif répondant (/opencloud/native/indexers/1.0), reçoit une liste de candidats.
  2. Consensus (round 1) : interroge tous les natifs configurés en parallèle (/opencloud/native/consensus/1.0, timeout 3 s, collecte sur 4 s). Un indexeur est confirmé si strictement plus de 50 % des natifs répondants le considèrent vivant.
  3. Consensus (round 2) : si le pool est insuffisant, les suggestions des natifs (indexeurs qu'ils connaissent mais qui n'étaient pas dans les candidats initiaux) sont soumises à un second round.

Avantages

  • La règle de majorité absolue empêche un natif compromis ou désynchronisé d'injecter des indexeurs fantômes.
  • Le double round permet de compléter le pool avec des alternatives connues des natifs sans sacrifier la vérification.
  • Si le fetch retourne un fallback (natif comme indexeur), le consensus est skippé — cohérent car il n'y a qu'une seule source.

Limites / risques

  • ⚠️ Avec un seul natif configuré (très courant en dev/test), le consensus est trivial (100 % d'un seul vote) — la règle de majorité ne protège rien dans ce cas.
  • ⚠️ fetchIndexersFromNative s'arrête au premier natif répondant (séquentiellement) : si ce natif a un cache périmé ou partiel, le nœud obtient un pool sous-optimal sans consulter les autres.
  • ⚠️ Le timeout de collecte global (4 s) est fixe : sur un réseau lent ou géographiquement distribué, des natifs valides peuvent être éliminés faute de réponse à temps.
  • ⚠️ replaceStaticIndexers ajoute sans jamais retirer d'anciens indexeurs expirés : le pool peut accumuler des entrées mortes que seul le heartbeat purge ensuite.

3.5 Self-delegation et offload loop

Fonctionnement

Si un natif ne dispose d'aucun indexeur vivant lors d'un handleNativeGetIndexers, il se désigne lui-même comme indexeur temporaire (selfDelegate) : il retourne sa propre adresse multiaddr et ajoute le demandeur dans responsiblePeers, dans la limite de maxFallbackPeers (50). Au-delà, la délégation est refusée et une réponse vide est retournée pour que le nœud tente un autre natif.

Toutes les 30 s, runOffloadLoop vérifie si des indexeurs réels sont de nouveau disponibles. Si oui, pour chaque peer responsable :

  • Stream présent : Reset() du stream heartbeat — le peer reçoit une erreur, déclenche replenishIndexersFromNative et migre vers de vrais indexeurs.
  • Stream absent (peer jamais admis par le scoring) : ClosePeer() sur la connexion réseau — le peer reconnecte et re-demande ses indexeurs au natif.

Avantages

  • Continuité de service : un nœud n'est jamais bloqué en l'absence temporaire d'indexeurs.
  • La migration est automatique et transparente pour le nœud.
  • Reset() (vs Close()) interrompt les deux sens du stream, garantissant que le peer reçoit bien une erreur.
  • La limite de 50 empêche le natif de se retrouver surchargé lors de pénuries prolongées.

Limites / risques

  • Offload sans stream corrigé : si le heartbeat n'avait jamais été enregistré dans StreamRecords (score < seuil — cas amplifié par le bug de scoring), l'offload échouait silencieusement et le peer restait dans responsiblePeers indéfiniment. → Branche else : ClosePeer() + suppression de responsiblePeers.
  • responsiblePeers illimité corrigé : le natif acceptait un nombre arbitraire de peers en self-delegation, devenant lui-même un indexeur surchargé. → selfDelegate vérifie len(responsiblePeers) >= maxFallbackPeers et retourne false si saturé.
  • ⚠️ La délégation reste non coordonnée entre natifs : un natif surchargé refuse (retourne vide) mais ne redirige pas explicitement vers un natif voisin qui aurait de la capacité.

3.6 Résilience du mesh natif

Fonctionnement

Quand le heartbeat vers un natif échoue, replenishNativesFromPeers tente de trouver un remplaçant dans cet ordre :

  1. fetchNativeFromNatives : demande à chaque natif vivant (/opencloud/native/peers/1.0) une adresse de natif inconnue.
  2. fetchNativeFromIndexers : demande à chaque indexeur connu (/opencloud/indexer/natives/1.0) ses natifs configurés.
  3. Si aucun remplaçant et remaining ≤ 1 : retryLostNative relance un ticker de 30 s qui retente la connexion directe au natif perdu.

EnsureNativePeers maintient des heartbeats de natif à natif via ProtocolHeartbeat, avec une unique goroutine couvrant toute la map StaticNatives.

Avantages

  • Le gossip multi-hop via indexeurs permet de retrouver un natif même si aucun pair direct ne le connaît.
  • retryLostNative gère le cas d'un seul natif (déploiement minimal).
  • La reconnexion automatique (retryLostNative) déclenche replenishIndexersIfNeeded pour restaurer aussi le pool d'indexeurs.

Limites / risques

  • Goroutines heartbeat multiples corrigé : EnsureNativePeers démarrait une goroutine SendHeartbeat par adresse native (N natifs → N goroutines → N² heartbeats par tick). → Utilisation de nativeMeshHeartbeatOnce : une seule goroutine itère sur StaticNatives.
  • ⚠️ retryLostNative tourne indéfiniment sans condition d'arrêt liée à la vie du processus (pas de context.Context). Si le binaire est gracefully shutdown, cette goroutine peut bloquer.
  • ⚠️ La découverte transitoire (natif → indexeur → natif) est à sens unique : un indexeur ne connaît que les natifs de sa propre config, pas les nouveaux natifs qui auraient rejoint après son démarrage.

3.7 DHT partagée

Fonctionnement

Tous les indexeurs et natifs participent à une DHT Kademlia (préfixe oc, mode ModeServer). Deux namespaces sont utilisés :

  • /node/<DID>PeerRecord JSON signé (publié par les indexeurs sur heartbeat de nœud).
  • /indexer/<PeerID>liveIndexerEntry JSON avec TTL (publié par les natifs).

Chaque natif lance refreshIndexersFromDHT (toutes les 30 s) qui ré-hydrate son cache local depuis la DHT pour les PeerIDs connus (knownPeerIDs) dont l'entrée locale a expiré.

Avantages

  • Persistance décentralisée : un record survit à la perte d'un seul natif ou indexeur.
  • Validation des entrées : PeerRecordValidator et IndexerRecordValidator rejettent les records malformés ou expirés au moment du PutValue.
  • L'index secondaire /name/<name> permet la résolution par nom humain.

Limites / risques

  • ⚠️ La DHT Kademlia en réseau privé (PSK) est fonctionnelle mais les nœuds bootstrap ne sont pas configurés explicitement : la découverte dépend de connexions déjà établies, ce qui peut ralentir la convergence au démarrage.
  • ⚠️ PutValue est réessayé en boucle infinie si "failed to find any peer in table" — une panne de réseau prolongée génère des goroutines bloquées.
  • ⚠️ Si la PSK est compromise, un attaquant peut écrire dans la DHT ; les liveIndexerEntry d'indexeurs ne sont pas signées, contrairement aux PeerRecord.
  • ⚠️ refreshIndexersFromDHT prune knownPeerIDs si la DHT n'a aucune entrée fraîche, mais ne prune pas liveIndexers — une entrée expirée reste en mémoire jusqu'au GC ou au prochain refresh.

3.8 PubSub gossip (indexer registry)

Fonctionnement

Quand un indexeur s'enregistre auprès d'un natif, ce dernier publie l'adresse sur le topic GossipSub oc-indexer-registry. Les autres natifs abonnés mettent à jour leur knownPeerIDs sans attendre la DHT.

Le TopicValidator rejette tout message dont le contenu n'est pas un multiaddr parseable valide avant qu'il n'atteigne la boucle de traitement.

Avantages

  • Dissémination quasi-instantanée entre natifs connectés.
  • Complément utile à la DHT pour les registrations récentes qui n'ont pas encore été persistées.
  • Le filtre syntaxique bloque les messages malformés avant propagation dans le mesh.

Limites / risques

  • TopicValidator sans validation corrigé : le validateur acceptait systématiquement tous les messages (return true), permettant à un natif compromis de gossiper n'importe quelle donnée. → Le validateur vérifie désormais que le message est un multiaddr parseable (pp.AddrInfoFromString).
  • ⚠️ La validation reste syntaxique uniquement : l'origine du message (l'émetteur est-il un natif légitime ?) n'est pas vérifiée.
  • ⚠️ Si le natif redémarre, il perd son abonnement et manque les messages publiés pendant son absence. La re-hydratation depuis la DHT compense, mais avec un délai pouvant aller jusqu'à 30 s.
  • ⚠️ Le gossip ne porte que le Addr de l'indexeur, pas sa TTL ni sa signature.

3.9 Streams applicatifs (node ↔ node)

Fonctionnement

StreamService gère les streams entre nœuds partenaires (relations PARTNER stockées en base) via des protocols dédiés (/opencloud/resource/*). Un heartbeat partenaire (ProtocolHeartbeatPartner) maintient les connexions actives. Les events sont routés via handleEvent et le système NATS en parallèle.

Avantages

  • TTL par protocol (PersistantStream, WaitResponse) adapte le comportement au type d'échange (longue durée pour le planner, courte pour les CRUDs).
  • La GC (gc() toutes les 8 s, démarrée une seule fois dans InitStream) libère rapidement les streams expirés.

Limites / risques

  • Fuite de goroutines GC corrigée : HandlePartnerHeartbeat appelait go s.StartGC(30s) à chaque heartbeat reçu (~20 s), créant un nouveau ticker goroutine infini à chaque appel. → Appel supprimé ; la GC lancée par InitStream est suffisante.
  • Boucle infinie sur EOF corrigée : readLoop effectuait s.Stream.Close(); continue après une erreur de décodage, re-tentant indéfiniment de lire un stream fermé. → Remplacé par return ; les defers (Close, delete) nettoient correctement.
  • ⚠️ La récupération de partenaires depuis conf.PeerIDS est marquée TO REMOVE : présence de code provisoire en production.

4. Tableau récapitulatif

Mécanisme Protocole Avantage principal État du risque
Heartbeat node→indexer /opencloud/heartbeat/1.0 Détection rapide de perte ⚠️ Stream TCP gelé non détecté
Scoring de confiance (inline dans heartbeat) Filtre les pairs instables Deadlock corrigé (seuil 40/75)
Enregistrement natif /opencloud/native/subscribe/1.0 TTL ample, cache immédiat TTL porté à 90 s
Fetch pool d'indexeurs /opencloud/native/indexers/1.0 Prend le 1er natif répondant ⚠️ Natif au cache périmé possible
Consensus /opencloud/native/consensus/1.0 Majorité absolue ⚠️ Trivial avec 1 seul natif
Self-delegation + offload (in-memory) Disponibilité sans indexeur Limite 50 peers + ClosePeer
Mesh natif /opencloud/native/peers/1.0 Gossip multi-hop Goroutines dédupliquées
DHT /oc/kad/1.0.0 Persistance décentralisée ⚠️ Retry infini, pas de bootstrap
PubSub registry oc-indexer-registry Dissémination rapide Validation multiaddr
Streams applicatifs /opencloud/resource/* TTL par protocol Fuite GC + EOF corrigés

5. Risques et limites globaux

Sécurité

  • ⚠️ Adresses auto-rapportées non vérifiées : le champ IndexersBinded dans le heartbeat est auto-déclaré par le nœud et sert à calculer la diversité. Un pair malveillant peut gonfler son score en déclarant de fausses adresses.
  • ⚠️ PSK comme seule barrière d'entrée : si la PSK est compromise (elle est statique et fichier-based), tout l'isolement réseau saute. Il n'y a pas de rotation de clé ni d'authentification supplémentaire par pair.
  • ⚠️ DHT sans ACL sur les entrées indexeur : la signature des PeerRecord est vérifiée à la lecture, mais les liveIndexerEntry ne sont pas signées. La validation PubSub bloque les multiaddrs invalides mais pas les adresses d'indexeurs légitimes usurpées.

Disponibilité

  • ⚠️ Single point of failure natif : avec un seul natif, la perte de celui-ci stoppe toute attribution d'indexeurs. retryLostNative pallie, mais sans indexeurs, les nœuds ne peuvent pas publier.
  • ⚠️ Bootstrap DHT : sans nœuds bootstrap explicites, la DHT met du temps à converger si les connexions initiales sont peu nombreuses.

Cohérence

  • ⚠️ replaceStaticIndexers n'efface jamais : d'anciens indexeurs morts restent dans StaticIndexers jusqu'à ce que le heartbeat échoue. Un nœud peut avoir un pool surévalué contenant des entrées inatteignables.
  • ⚠️ TimeWatcher global : défini une seule fois au démarrage de ConnectToIndexers. Si l'indexeur tourne depuis longtemps, les nouveaux nœuds auront un uptime_ratio durablement faible. Le seuil abaissé à 40 pour le premier heartbeat atténue l'impact initial, mais les heartbeats suivants devront accumuler un uptime suffisant.

6. Pistes d'amélioration

Les pistes déjà implémentées sont marquées . Les pistes ouvertes restent à traiter.

Score : double seuil pour les nouveaux peers

Remplacer le seuil binaireImplémenté : seuil à 40 pour le premier heartbeat (peer absent de StreamRecords), 75 pour les suivants. Un peer peut désormais être admis dès sa première connexion sans bloquer sur l'uptime nul. Fichier : common/common_stream.go, CheckHeartbeat

TTL indexeur aligné avec l'intervalle de renouvellement

TTL de 66 s trop proche de 60 sImplémenté : IndexerTTL passé à 90 s. Fichier : indexer/native.go

Limite de la self-delegation

responsiblePeers illimitéImplémenté : selfDelegate retourne false quand len(responsiblePeers) >= maxFallbackPeers (50). Le site d'appel retourne une réponse vide et logue un warning. Fichier : indexer/native.go

Validation PubSub des adresses gossipées

TopicValidator accepte toutImplémenté : le validateur vérifie que le message est un multiaddr parseable via pp.AddrInfoFromString. Fichier : indexer/native.go, subscribeIndexerRegistry

Goroutines heartbeat dédupliquées dans EnsureNativePeers

Une goroutine par adresse nativeImplémenté : nativeMeshHeartbeatOnce garantit qu'une seule goroutine SendHeartbeat couvre toute la map StaticNatives. Fichier : common/native_stream.go

Fuite de goroutines GC dans HandlePartnerHeartbeat

go s.StartGC(30s) à chaque heartbeatImplémenté : appel supprimé ; la GC de InitStream est suffisante. Fichier : stream/service.go

Boucle infinie sur EOF dans readLoop

continue après Stream.Close()Implémenté : remplacé par return pour laisser les defers nettoyer proprement. Fichier : stream/service.go


⚠️ Fetch pool : interroger tous les natifs en parallèle

fetchIndexersFromNative s'arrête au premier natif répondant. Interroger tous les natifs en parallèle et fusionner les listes (similairement à clientSideConsensus) éviterait qu'un natif au cache périmé fournisse un pool sous-optimal.

⚠️ Consensus avec quorum configurable

Le seuil de confirmation (count*2 > total) est câblé en dur. Le rendre configurable (ex. consensus_quorum: 0.67) permettrait de durcir la règle sur des déploiements à 3+ natifs sans modifier le code.

⚠️ Désenregistrement explicite

Ajouter un protocole /opencloud/native/unsubscribe/1.0 : quand un indexeur s'arrête proprement, il notifie les natifs pour invalider son TTL immédiatement plutôt qu'attendre 90 s.

⚠️ Bootstrap DHT explicite

Configurer les natifs comme nœuds bootstrap DHT via dht.BootstrapPeers pour accélérer la convergence Kademlia au démarrage.

⚠️ Context propagé dans les goroutines longue durée

retryLostNative, refreshIndexersFromDHT et runOffloadLoop ne reçoivent aucun context.Context. Les passer depuis InitNative permettrait un arrêt propre lors du shutdown du processus.

⚠️ Redirection explicite lors du refus de self-delegation

Quand un natif refuse la self-delegation (pool saturé), retourner vide force le nœud à réessayer sans lui indiquer vers qui se tourner. Une liste de natifs alternatifs dans la réponse (AlternativeNatives []string) permettrait au nœud de trouver directement un natif moins chargé.