24 KiB
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
- Vue d'ensemble
- Hiérarchie des rôles
- 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)
- Tableau récapitulatif
- Risques et limites globaux
- 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.
- ⚠️
StaticIndexersest une map partagée globale : si deux goroutines appellentreplenishIndexersFromNativesimultané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.
- ⚠️
diversityest 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 :
- Stocke l'entrée en cache local avec un TTL de 90 s (
IndexerTTL). - Gossipe le
PeerIDsur le topic PubSuboc-indexer-registryaux autres natifs. - 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
handleNativeGetIndexerssans 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.
→
IndexerTTLporté à 90 s (+50 %). - ⚠️ Si le
PutValueDHT é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. - ⚠️
RegisterWithNativeignore les adresses en127.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 :
- Fetch : envoie
GetIndexersRequestau premier natif répondant (/opencloud/native/indexers/1.0), reçoit une liste de candidats. - 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. - 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.
- ⚠️
fetchIndexersFromNatives'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.
- ⚠️
replaceStaticIndexersajoute 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éclenchereplenishIndexersFromNativeet 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()(vsClose()) 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 dansresponsiblePeersindéfiniment. → Brancheelse:ClosePeer()+ suppression deresponsiblePeers. - ✅
responsiblePeersillimité corrigé : le natif acceptait un nombre arbitraire de peers en self-delegation, devenant lui-même un indexeur surchargé. →selfDelegatevérifielen(responsiblePeers) >= maxFallbackPeerset retournefalsesi 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 :
fetchNativeFromNatives: demande à chaque natif vivant (/opencloud/native/peers/1.0) une adresse de natif inconnue.fetchNativeFromIndexers: demande à chaque indexeur connu (/opencloud/indexer/natives/1.0) ses natifs configurés.- Si aucun remplaçant et
remaining ≤ 1:retryLostNativerelance 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.
retryLostNativegère le cas d'un seul natif (déploiement minimal).- La reconnexion automatique (
retryLostNative) déclenchereplenishIndexersIfNeededpour restaurer aussi le pool d'indexeurs.
Limites / risques
- ✅ Goroutines heartbeat multiples corrigé :
EnsureNativePeersdémarrait une goroutineSendHeartbeatpar adresse native (N natifs → N goroutines → N² heartbeats par tick). → Utilisation denativeMeshHeartbeatOnce: une seule goroutine itère surStaticNatives. - ⚠️
retryLostNativetourne indéfiniment sans condition d'arrêt liée à la vie du processus (pas decontext.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>→PeerRecordJSON signé (publié par les indexeurs sur heartbeat de nœud)./indexer/<PeerID>→liveIndexerEntryJSON 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 :
PeerRecordValidatoretIndexerRecordValidatorrejettent les records malformés ou expirés au moment duPutValue. - 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.
- ⚠️
PutValueest 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
liveIndexerEntryd'indexeurs ne sont pas signées, contrairement auxPeerRecord. - ⚠️
refreshIndexersFromDHTpruneknownPeerIDssi la DHT n'a aucune entrée fraîche, mais ne prune pasliveIndexers— 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
- ✅
TopicValidatorsans 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
Addrde 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 dansInitStream) libère rapidement les streams expirés.
Limites / risques
- ✅ Fuite de goroutines GC corrigée :
HandlePartnerHeartbeatappelaitgo s.StartGC(30s)à chaque heartbeat reçu (~20 s), créant un nouveau ticker goroutine infini à chaque appel. → Appel supprimé ; la GC lancée parInitStreamest suffisante. - ✅ Boucle infinie sur EOF corrigée :
readLoopeffectuaits.Stream.Close(); continueaprès une erreur de décodage, re-tentant indéfiniment de lire un stream fermé. → Remplacé parreturn; les defers (Close,delete) nettoient correctement. - ⚠️ La récupération de partenaires depuis
conf.PeerIDSest marquéeTO 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
IndexersBindeddans 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
PeerRecordest vérifiée à la lecture, mais lesliveIndexerEntryne 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.
retryLostNativepallie, 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
- ⚠️
replaceStaticIndexersn'efface jamais : d'anciens indexeurs morts restent dansStaticIndexersjusqu'à ce que le heartbeat échoue. Un nœud peut avoir un pool surévalué contenant des entrées inatteignables. - ⚠️
TimeWatcherglobal : défini une seule fois au démarrage deConnectToIndexers. Si l'indexeur tourne depuis longtemps, les nouveaux nœuds auront unuptime_ratiodurablement 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 binaire — Implé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 s — Implémenté : IndexerTTL passé à 90 s.
Fichier : indexer/native.go
✅ Limite de la self-delegation
— Implémenté : responsiblePeers illimité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
— Implémenté : le validateur vérifie que le message
est un multiaddr parseable via TopicValidator accepte toutpp.AddrInfoFromString.
Fichier : indexer/native.go, subscribeIndexerRegistry
✅ Goroutines heartbeat dédupliquées dans EnsureNativePeers
Une goroutine par adresse native — Implémenté : nativeMeshHeartbeatOnce
garantit qu'une seule goroutine SendHeartbeat couvre toute la map StaticNatives.
Fichier : common/native_stream.go
✅ Fuite de goroutines GC dans HandlePartnerHeartbeat
— Implémenté : appel supprimé ; la GC
de go s.StartGC(30s) à chaque heartbeatInitStream est suffisante.
Fichier : stream/service.go
✅ Boucle infinie sur EOF dans readLoop
— Implémenté : remplacé par continue après Stream.Close()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é.