package common import ( "context" "math/rand" "strings" "time" oclib "cloud.o-forge.io/core/oc-lib" "github.com/ipfs/go-cid" dht "github.com/libp2p/go-libp2p-kad-dht" "github.com/libp2p/go-libp2p/core/host" pp "github.com/libp2p/go-libp2p/core/peer" ma "github.com/multiformats/go-multiaddr" mh "github.com/multiformats/go-multihash" ) // FilterLoopbackAddrs strips loopback (127.x, ::1) and unspecified addresses // from an AddrInfo so we never hand peers an address they cannot dial externally. func FilterLoopbackAddrs(ai pp.AddrInfo) pp.AddrInfo { filtered := make([]ma.Multiaddr, 0, len(ai.Addrs)) for _, addr := range ai.Addrs { ip, err := ExtractIP(addr.String()) if err != nil || ip.IsLoopback() || ip.IsUnspecified() { continue } filtered = append(filtered, addr) } return pp.AddrInfo{ID: ai.ID, Addrs: filtered} } // RecommendedHeartbeatInterval is the target period between heartbeat ticks. // Indexers use this as the DHT Provide refresh interval. const RecommendedHeartbeatInterval = 60 * time.Second // discoveryDHT is the DHT instance used for indexer discovery. // Set by SetDiscoveryDHT once the indexer service initialises its DHT. var discoveryDHT *dht.IpfsDHT // SetDiscoveryDHT stores the DHT instance used by replenishIndexersFromDHT. // Called by NewIndexerService once the DHT is ready. func SetDiscoveryDHT(d *dht.IpfsDHT) { discoveryDHT = d } // initNodeDHT creates a lightweight DHT client for pure nodes (no IndexerService). // Uses the seed indexers as bootstrap peers. Called lazily by ConnectToIndexers // when discoveryDHT is still nil after the initial warm-up delay. func initNodeDHT(h host.Host, seeds []Entry) { logger := oclib.GetLogger() bootstrapPeers := []pp.AddrInfo{} for _, s := range seeds { bootstrapPeers = append(bootstrapPeers, *s.Info) } d, err := dht.New(context.Background(), h, dht.Mode(dht.ModeClient), dht.ProtocolPrefix("oc"), dht.BootstrapPeers(bootstrapPeers...), ) if err != nil { logger.Warn().Err(err).Msg("[dht] node DHT client init failed") return } SetDiscoveryDHT(d) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := d.Bootstrap(ctx); err != nil { logger.Warn().Err(err).Msg("[dht] node DHT client bootstrap failed") } logger.Info().Msg("[dht] node DHT client ready") } // IndexerCID returns the well-known CID under which all indexers advertise. func IndexerCID() cid.Cid { h, _ := mh.Sum([]byte("/opencloud/indexers"), mh.SHA2_256, -1) return cid.NewCidV1(cid.Raw, h) } // DiscoverIndexersFromDHT uses the DHT to find up to count indexers advertising // under the well-known key. Excludes self. Resolves addresses when the provider // record carries none. func DiscoverIndexersFromDHT(h host.Host, d *dht.IpfsDHT, count int) []pp.AddrInfo { logger := oclib.GetLogger() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() c := IndexerCID() ch := d.FindProvidersAsync(ctx, c, count*2) seen := map[pp.ID]struct{}{} var results []pp.AddrInfo for ai := range ch { if ai.ID == h.ID() { continue } if _, dup := seen[ai.ID]; dup { continue } seen[ai.ID] = struct{}{} if len(ai.Addrs) == 0 { resolved, err := d.FindPeer(ctx, ai.ID) if err != nil { logger.Warn().Str("peer", ai.ID.String()).Msg("[dht] no addrs and FindPeer failed, skipping") continue } ai = resolved } ai = FilterLoopbackAddrs(ai) if len(ai.Addrs) == 0 { continue } results = append(results, ai) if len(results) >= count { break } } logger.Info().Int("found", len(results)).Msg("[dht] indexer discovery complete") return results } // SelectByFillRate picks up to want providers using fill-rate weighted random // selection w(F) = F*(1-F) — peaks at F=0.5, prefers less-loaded indexers. // Providers with unknown fill rate receive F=0.5 (neutral prior). // Enforces subnet /24 diversity: at most one indexer per /24. func SelectByFillRate(providers []pp.AddrInfo, fillRates map[pp.ID]float64, want int) []pp.AddrInfo { if len(providers) == 0 || want <= 0 { return nil } type weighted struct { ai pp.AddrInfo weight float64 } ws := make([]weighted, 0, len(providers)) for _, ai := range providers { f, ok := fillRates[ai.ID] if !ok { f = 0.5 } ws = append(ws, weighted{ai: ai, weight: f * (1 - f)}) } // Shuffle first for fairness among equal-weight peers. rand.Shuffle(len(ws), func(i, j int) { ws[i], ws[j] = ws[j], ws[i] }) // Sort descending by weight (simple insertion sort — small N). for i := 1; i < len(ws); i++ { for j := i; j > 0 && ws[j].weight > ws[j-1].weight; j-- { ws[j], ws[j-1] = ws[j-1], ws[j] } } subnets := map[string]struct{}{} var selected []pp.AddrInfo for _, w := range ws { if len(selected) >= want { break } subnet := subnetOf(w.ai) if subnet != "" { if _, dup := subnets[subnet]; dup { continue } subnets[subnet] = struct{}{} } selected = append(selected, w.ai) } return selected } // subnetOf returns the /24 subnet string for the first non-loopback address of ai. func subnetOf(ai pp.AddrInfo) string { for _, ma := range ai.Addrs { ip, err := ExtractIP(ma.String()) if err != nil || ip.IsLoopback() { continue } parts := strings.Split(ip.String(), ".") if len(parts) >= 3 { return parts[0] + "." + parts[1] + "." + parts[2] } } return "" } // replenishIndexersFromDHT is called when an indexer heartbeat fails and more // indexers are needed. Queries the DHT and adds fresh entries to StaticIndexers. func replenishIndexersFromDHT(h host.Host, need int) { if need <= 0 || discoveryDHT == nil { return } logger := oclib.GetLogger() logger.Info().Int("need", need).Msg("[dht] replenishing indexer pool from DHT") providers := DiscoverIndexersFromDHT(h, discoveryDHT, need*3) selected := SelectByFillRate(providers, nil, need) if len(selected) == 0 { logger.Warn().Msg("[dht] no indexers found in DHT for replenishment") return } added := 0 for _, ai := range selected { addr := addrKey(ai) if !Indexers.ExistsAddr(addr) { adCopy := ai Indexers.SetAddr(addr, &adCopy) added++ } } if added > 0 { logger.Info().Int("added", added).Msg("[dht] indexers added from DHT") Indexers.NudgeIt() } } // addrKey returns the canonical map key for an AddrInfo. // The PeerID is used as key so the same peer is never stored twice regardless // of which of its addresses was seen first. func addrKey(ai pp.AddrInfo) string { return ai.ID.String() }