2026-01-30 16:57:36 +01:00
|
|
|
package indexer
|
|
|
|
|
|
|
|
|
|
import (
|
2026-02-04 11:35:19 +01:00
|
|
|
"context"
|
2026-01-30 16:57:36 +01:00
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
2026-03-05 15:22:02 +01:00
|
|
|
"io"
|
2026-03-11 16:28:15 +01:00
|
|
|
"math/rand"
|
2026-01-30 16:57:36 +01:00
|
|
|
"oc-discovery/daemons/node/common"
|
2026-03-03 16:38:24 +01:00
|
|
|
"strings"
|
2026-01-30 16:57:36 +01:00
|
|
|
"time"
|
|
|
|
|
|
2026-02-03 15:25:15 +01:00
|
|
|
oclib "cloud.o-forge.io/core/oc-lib"
|
2026-03-17 11:57:22 +01:00
|
|
|
"cloud.o-forge.io/core/oc-lib/dbs"
|
2026-01-30 16:57:36 +01:00
|
|
|
pp "cloud.o-forge.io/core/oc-lib/models/peer"
|
|
|
|
|
"cloud.o-forge.io/core/oc-lib/models/utils"
|
|
|
|
|
"cloud.o-forge.io/core/oc-lib/tools"
|
|
|
|
|
"github.com/libp2p/go-libp2p/core/crypto"
|
|
|
|
|
"github.com/libp2p/go-libp2p/core/network"
|
2026-03-11 16:28:15 +01:00
|
|
|
lpp "github.com/libp2p/go-libp2p/core/peer"
|
2026-01-30 16:57:36 +01:00
|
|
|
)
|
|
|
|
|
|
2026-04-08 10:04:41 +02:00
|
|
|
// DefaultTTLSeconds is the default TTL for peer records when the publisher
|
|
|
|
|
// does not declare a custom TTL. Exported so the node package can reference it.
|
|
|
|
|
const DefaultTTLSeconds = 120
|
|
|
|
|
|
|
|
|
|
// maxTTLSeconds caps how far in the future a publisher can set their ExpiryDate.
|
|
|
|
|
const maxTTLSeconds = 86400 // 24h
|
|
|
|
|
|
|
|
|
|
// tombstoneTTL is how long a signed delete record stays alive in the DHT —
|
|
|
|
|
// long enough to propagate everywhere, short enough not to linger forever.
|
|
|
|
|
const tombstoneTTL = 10 * time.Minute
|
|
|
|
|
|
2026-03-03 16:38:24 +01:00
|
|
|
type PeerRecordPayload struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
DID string `json:"did"`
|
|
|
|
|
PubKey []byte `json:"pub_key"`
|
|
|
|
|
ExpiryDate time.Time `json:"expiry_date"`
|
2026-04-08 10:04:41 +02:00
|
|
|
// TTLSeconds is the publisher's declared lifetime for this record in seconds.
|
|
|
|
|
// 0 means "use the default (120 s)". Included in the signed payload so it
|
|
|
|
|
// cannot be altered by an intermediary.
|
|
|
|
|
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
2026-03-03 16:38:24 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-30 16:57:36 +01:00
|
|
|
type PeerRecord struct {
|
2026-03-03 16:38:24 +01:00
|
|
|
PeerRecordPayload
|
2026-04-08 10:04:41 +02:00
|
|
|
PeerID string `json:"peer_id"`
|
|
|
|
|
APIUrl string `json:"api_url"`
|
|
|
|
|
StreamAddress string `json:"stream_address"`
|
|
|
|
|
NATSAddress string `json:"nats_address"`
|
|
|
|
|
WalletAddress string `json:"wallet_address"`
|
|
|
|
|
Location *pp.PeerLocation `json:"location,omitempty"`
|
|
|
|
|
Signature []byte `json:"signature"`
|
2026-01-30 16:57:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *PeerRecord) Sign() error {
|
2026-02-09 13:28:00 +01:00
|
|
|
priv, err := tools.LoadKeyFromFilePrivate()
|
2026-01-30 16:57:36 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-03-03 16:38:24 +01:00
|
|
|
payload, _ := json.Marshal(p.PeerRecordPayload)
|
2026-01-30 16:57:36 +01:00
|
|
|
b, err := common.Sign(priv, payload)
|
|
|
|
|
p.Signature = b
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *PeerRecord) Verify() (crypto.PubKey, error) {
|
|
|
|
|
pubKey, err := crypto.UnmarshalPublicKey(p.PubKey) // retrieve pub key in message
|
|
|
|
|
if err != nil {
|
|
|
|
|
return pubKey, err
|
|
|
|
|
}
|
2026-03-03 16:38:24 +01:00
|
|
|
payload, _ := json.Marshal(p.PeerRecordPayload)
|
2026-01-30 16:57:36 +01:00
|
|
|
|
2026-03-03 16:38:24 +01:00
|
|
|
if ok, _ := pubKey.Verify(payload, p.Signature); !ok { // verify minimal message was sign per pubKey
|
2026-01-30 16:57:36 +01:00
|
|
|
return pubKey, errors.New("invalid signature")
|
|
|
|
|
}
|
|
|
|
|
return pubKey, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (pr *PeerRecord) ExtractPeer(ourkey string, key string, pubKey crypto.PubKey) (bool, *pp.Peer, error) {
|
2026-02-03 15:25:15 +01:00
|
|
|
pubBytes, err := crypto.MarshalPublicKey(pubKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, nil, err
|
|
|
|
|
}
|
2026-01-30 16:57:36 +01:00
|
|
|
rel := pp.NONE
|
|
|
|
|
if ourkey == key { // at this point is PeerID is same as our... we are... thats our peer INFO
|
|
|
|
|
rel = pp.SELF
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p := &pp.Peer{
|
|
|
|
|
AbstractObject: utils.AbstractObject{
|
|
|
|
|
UUID: pr.DID,
|
|
|
|
|
Name: pr.Name,
|
|
|
|
|
},
|
|
|
|
|
Relation: rel, // VERIFY.... it crush nothing
|
|
|
|
|
PeerID: pr.PeerID,
|
|
|
|
|
PublicKey: base64.StdEncoding.EncodeToString(pubBytes),
|
|
|
|
|
APIUrl: pr.APIUrl,
|
|
|
|
|
StreamAddress: pr.StreamAddress,
|
|
|
|
|
NATSAddress: pr.NATSAddress,
|
|
|
|
|
WalletAddress: pr.WalletAddress,
|
2026-04-08 10:04:41 +02:00
|
|
|
Location: pr.Location,
|
2026-01-30 16:57:36 +01:00
|
|
|
}
|
2026-02-18 14:32:44 +01:00
|
|
|
if time.Now().UTC().After(pr.ExpiryDate) {
|
2026-01-30 16:57:36 +01:00
|
|
|
return pp.SELF == p.Relation, nil, errors.New("peer " + key + " is offline")
|
|
|
|
|
}
|
|
|
|
|
return pp.SELF == p.Relation, p, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 10:04:41 +02:00
|
|
|
// TombstonePayload is the signed body of a delete request.
|
|
|
|
|
// Only the owner's private key can produce a valid signature over this payload.
|
|
|
|
|
type TombstonePayload struct {
|
|
|
|
|
DID string `json:"did"`
|
|
|
|
|
PeerID string `json:"peer_id"`
|
|
|
|
|
DeletedAt time.Time `json:"deleted_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TombstoneRecord is stored in the DHT at /node/{DID} to signal that a peer
|
|
|
|
|
// has voluntarily left the network. The Tombstone bool field acts as a
|
|
|
|
|
// discriminator so validators can distinguish it from a live PeerRecord.
|
|
|
|
|
type TombstoneRecord struct {
|
|
|
|
|
TombstonePayload
|
|
|
|
|
PubKey []byte `json:"pub_key"`
|
|
|
|
|
Tombstone bool `json:"tombstone"`
|
|
|
|
|
Signature []byte `json:"signature"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ts *TombstoneRecord) Verify() (crypto.PubKey, error) {
|
|
|
|
|
pubKey, err := crypto.UnmarshalPublicKey(ts.PubKey)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
payload, _ := json.Marshal(ts.TombstonePayload)
|
|
|
|
|
if ok, _ := pubKey.Verify(payload, ts.Signature); !ok {
|
|
|
|
|
return nil, errors.New("invalid tombstone signature")
|
|
|
|
|
}
|
|
|
|
|
return pubKey, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isTombstone returns true if data is a valid, well-formed TombstoneRecord.
|
|
|
|
|
func isTombstone(data []byte) bool {
|
|
|
|
|
var ts TombstoneRecord
|
|
|
|
|
return json.Unmarshal(data, &ts) == nil && ts.Tombstone
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 16:57:36 +01:00
|
|
|
type GetValue struct {
|
2026-03-05 15:22:02 +01:00
|
|
|
Key string `json:"key"`
|
|
|
|
|
PeerID string `json:"peer_id,omitempty"`
|
2026-01-30 16:57:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type GetResponse struct {
|
2026-02-04 11:35:19 +01:00
|
|
|
Found bool `json:"found"`
|
|
|
|
|
Records map[string]PeerRecord `json:"records,omitempty"`
|
2026-01-30 16:57:36 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 13:29:50 +01:00
|
|
|
func (ix *IndexerService) genKey(did string) string {
|
|
|
|
|
return "/node/" + did
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 16:38:24 +01:00
|
|
|
func (ix *IndexerService) genPIDKey(peerID string) string {
|
|
|
|
|
return "/pid/" + peerID
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:57:22 +01:00
|
|
|
// isPeerKnown is the stream-level gate: returns true if pid is allowed.
|
|
|
|
|
// Check order (fast → slow):
|
|
|
|
|
// 1. In-memory stream records — currently heartbeating to this indexer.
|
|
|
|
|
// 2. Local DB by peer_id — known peer, blacklist enforced here.
|
|
|
|
|
// 3. DHT /pid/{peerID} → /node/{DID} — registered on any indexer.
|
|
|
|
|
//
|
|
|
|
|
// ProtocolHeartbeat and ProtocolPublish handlers do NOT call this — they are
|
|
|
|
|
// the streams through which a node first makes itself known.
|
|
|
|
|
func (ix *IndexerService) isPeerKnown(pid lpp.ID) bool {
|
|
|
|
|
// 1. Fast path: active heartbeat session.
|
|
|
|
|
ix.StreamMU.RLock()
|
|
|
|
|
_, active := ix.StreamRecords[common.ProtocolHeartbeat][pid]
|
|
|
|
|
ix.StreamMU.RUnlock()
|
|
|
|
|
if active {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
// 2. Local DB: known peer (handles blacklist).
|
|
|
|
|
access := oclib.NewRequestAdmin(oclib.LibDataEnum(oclib.PEER), nil)
|
|
|
|
|
results := access.Search(&dbs.Filters{
|
|
|
|
|
And: map[string][]dbs.Filter{
|
|
|
|
|
"peer_id": {{Operator: dbs.EQUAL.String(), Value: pid.String()}},
|
|
|
|
|
},
|
|
|
|
|
}, pid.String(), false)
|
|
|
|
|
for _, item := range results.Data {
|
|
|
|
|
p, ok := item.(*pp.Peer)
|
|
|
|
|
if !ok || p.PeerID != pid.String() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
return p.Relation != pp.BLACKLIST
|
|
|
|
|
}
|
|
|
|
|
// 3. DHT lookup by peer_id.
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
|
|
|
did, err := ix.DHT.GetValue(ctx, ix.genPIDKey(pid.String()))
|
|
|
|
|
cancel()
|
|
|
|
|
if err != nil || len(did) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 3*time.Second)
|
2026-04-08 10:04:41 +02:00
|
|
|
val, err := ix.DHT.GetValue(ctx2, ix.genKey(string(did)))
|
2026-03-17 11:57:22 +01:00
|
|
|
cancel2()
|
2026-04-08 10:04:41 +02:00
|
|
|
return err == nil && !isTombstone(val)
|
2026-03-17 11:57:22 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 16:38:24 +01:00
|
|
|
func (ix *IndexerService) initNodeHandler() {
|
2026-02-03 15:25:15 +01:00
|
|
|
logger := oclib.GetLogger()
|
2026-03-03 16:38:24 +01:00
|
|
|
logger.Info().Msg("Init Node Handler")
|
2026-03-11 16:28:15 +01:00
|
|
|
|
2026-03-03 16:38:24 +01:00
|
|
|
// Each heartbeat from a node carries a freshly signed PeerRecord.
|
|
|
|
|
// Republish it to the DHT so the record never expires as long as the node
|
|
|
|
|
// is alive — no separate publish stream needed from the node side.
|
2026-03-05 15:22:02 +01:00
|
|
|
ix.AfterHeartbeat = func(hb *common.Heartbeat) {
|
|
|
|
|
// Priority 1: use the fresh signed PeerRecord embedded in the heartbeat.
|
|
|
|
|
// Each heartbeat tick, the node re-signs with ExpiryDate = now+2min, so
|
|
|
|
|
// this record is always fresh. Fetching from DHT would give a stale expiry.
|
2026-03-03 16:38:24 +01:00
|
|
|
var rec PeerRecord
|
2026-03-05 15:22:02 +01:00
|
|
|
if len(hb.Record) > 0 {
|
|
|
|
|
if err := json.Unmarshal(hb.Record, &rec); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Msg("indexer: heartbeat embedded record unmarshal failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback: node didn't embed a record yet (first heartbeat before claimInfo).
|
|
|
|
|
// Fetch from DHT using the DID resolved by HandleHeartbeat.
|
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
res, err := ix.DHT.GetValue(ctx2, ix.genKey(hb.DID))
|
|
|
|
|
cancel2()
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Warn().Err(err).Str("did", hb.DID).Msg("indexer: DHT fetch for refresh failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(res, &rec); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Str("did", hb.DID).Msg("indexer: heartbeat record unmarshal failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-03 15:25:15 +01:00
|
|
|
}
|
2026-03-03 16:38:24 +01:00
|
|
|
if _, err := rec.Verify(); err != nil {
|
2026-03-05 15:22:02 +01:00
|
|
|
logger.Warn().Err(err).Str("did", rec.DID).Msg("indexer: heartbeat record signature invalid")
|
2026-03-03 16:38:24 +01:00
|
|
|
return
|
2026-02-03 15:25:15 +01:00
|
|
|
}
|
2026-04-08 10:04:41 +02:00
|
|
|
// Don't republish if a tombstone was recently stored for this DID:
|
|
|
|
|
// the peer explicitly left and we must not re-animate their record.
|
|
|
|
|
ix.deletedDIDsMu.Lock()
|
|
|
|
|
if t, ok := ix.deletedDIDs[rec.DID]; ok {
|
|
|
|
|
if time.Since(t) < tombstoneTTL {
|
|
|
|
|
ix.deletedDIDsMu.Unlock()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// tombstoneTTL elapsed — peer is allowed to re-register.
|
|
|
|
|
delete(ix.deletedDIDs, rec.DID)
|
|
|
|
|
}
|
|
|
|
|
ix.deletedDIDsMu.Unlock()
|
2026-03-17 11:57:22 +01:00
|
|
|
// Keep StreamRecord.Record in sync so BuildHeartbeatResponse always
|
|
|
|
|
// sees a populated PeerRecord (Name, DID, etc.) regardless of whether
|
|
|
|
|
// handleNodePublish ran before or after the heartbeat stream was opened.
|
|
|
|
|
if pid, err := lpp.Decode(rec.PeerID); err == nil {
|
|
|
|
|
ix.StreamMU.Lock()
|
|
|
|
|
if srec, ok := ix.StreamRecords[common.ProtocolHeartbeat][pid]; ok {
|
|
|
|
|
srec.Record = rec
|
|
|
|
|
}
|
|
|
|
|
ix.StreamMU.Unlock()
|
|
|
|
|
}
|
2026-03-03 16:38:24 +01:00
|
|
|
data, err := json.Marshal(rec)
|
2026-02-03 15:25:15 +01:00
|
|
|
if err != nil {
|
2026-03-03 16:38:24 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
if err := ix.DHT.PutValue(ctx, ix.genKey(rec.DID), data); err != nil {
|
2026-03-17 11:57:22 +01:00
|
|
|
logger.Warn().Err(err).Str("did", rec.DID).Msg("indexer: DHT refresh /node/ failed")
|
2026-03-03 16:38:24 +01:00
|
|
|
}
|
2026-03-05 15:22:02 +01:00
|
|
|
cancel()
|
2026-03-17 11:57:22 +01:00
|
|
|
// /pid/ is written unconditionally — the gater queries by PeerID and this
|
|
|
|
|
// index must stay fresh regardless of whether the /node/ write succeeded.
|
2026-03-11 19:29:39 +01:00
|
|
|
if rec.PeerID != "" {
|
2026-03-03 16:38:24 +01:00
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
2026-03-17 11:57:22 +01:00
|
|
|
if err := ix.DHT.PutValue(ctx2, ix.genPIDKey(rec.PeerID), []byte(rec.DID)); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Str("pid", rec.PeerID).Msg("indexer: DHT refresh /pid/ failed")
|
|
|
|
|
}
|
2026-03-03 16:38:24 +01:00
|
|
|
cancel2()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ix.Host.SetStreamHandler(common.ProtocolHeartbeat, ix.HandleHeartbeat)
|
|
|
|
|
ix.Host.SetStreamHandler(common.ProtocolPublish, ix.handleNodePublish)
|
|
|
|
|
ix.Host.SetStreamHandler(common.ProtocolGet, ix.handleNodeGet)
|
2026-04-08 10:04:41 +02:00
|
|
|
ix.Host.SetStreamHandler(common.ProtocolDelete, ix.handleNodeDelete)
|
|
|
|
|
ix.Host.SetStreamHandler(common.ProtocolIndirectProbe, ix.handleIndirectProbe)
|
2026-03-11 16:28:15 +01:00
|
|
|
ix.Host.SetStreamHandler(common.ProtocolIndexerCandidates, ix.handleCandidateRequest)
|
|
|
|
|
ix.initSearchHandlers()
|
2026-03-09 14:57:41 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-11 16:28:15 +01:00
|
|
|
// handleCandidateRequest responds to a node's consensus candidate request.
|
|
|
|
|
// Returns a random sample of indexers from the local DHT cache.
|
|
|
|
|
func (ix *IndexerService) handleCandidateRequest(s network.Stream) {
|
|
|
|
|
defer s.Close()
|
2026-03-17 11:57:22 +01:00
|
|
|
if !ix.isPeerKnown(s.Conn().RemotePeer()) {
|
|
|
|
|
logger := oclib.GetLogger()
|
|
|
|
|
logger.Warn().Str("peer", s.Conn().RemotePeer().String()).Msg("[candidates] unknown peer, rejecting stream")
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-11 16:28:15 +01:00
|
|
|
s.SetDeadline(time.Now().Add(5 * time.Second))
|
|
|
|
|
var req common.IndexerCandidatesRequest
|
|
|
|
|
if err := json.NewDecoder(s).Decode(&req); err != nil {
|
2026-03-09 14:57:41 +01:00
|
|
|
return
|
|
|
|
|
}
|
2026-03-11 16:28:15 +01:00
|
|
|
if req.Count <= 0 || req.Count > 10 {
|
|
|
|
|
req.Count = 3
|
|
|
|
|
}
|
|
|
|
|
ix.dhtCacheMu.RLock()
|
|
|
|
|
cache := make([]dhtCacheEntry, len(ix.dhtCache))
|
|
|
|
|
copy(cache, ix.dhtCache)
|
|
|
|
|
ix.dhtCacheMu.RUnlock()
|
|
|
|
|
|
|
|
|
|
// Shuffle for randomness: each voter offers a different subset.
|
|
|
|
|
rand.Shuffle(len(cache), func(i, j int) { cache[i], cache[j] = cache[j], cache[i] })
|
|
|
|
|
candidates := make([]lpp.AddrInfo, 0, req.Count)
|
|
|
|
|
for _, e := range cache {
|
|
|
|
|
if len(candidates) >= req.Count {
|
|
|
|
|
break
|
2026-03-09 14:57:41 +01:00
|
|
|
}
|
2026-03-11 16:28:15 +01:00
|
|
|
candidates = append(candidates, e.AI)
|
2026-03-09 14:57:41 +01:00
|
|
|
}
|
2026-03-11 16:28:15 +01:00
|
|
|
json.NewEncoder(s).Encode(common.IndexerCandidatesResponse{Candidates: candidates})
|
2026-03-03 16:38:24 +01:00
|
|
|
}
|
2026-01-30 16:57:36 +01:00
|
|
|
|
2026-03-03 16:38:24 +01:00
|
|
|
func (ix *IndexerService) handleNodePublish(s network.Stream) {
|
|
|
|
|
defer s.Close()
|
|
|
|
|
logger := oclib.GetLogger()
|
2026-03-11 16:28:15 +01:00
|
|
|
remotePeer := s.Conn().RemotePeer()
|
|
|
|
|
if err := ix.behavior.RecordPublish(remotePeer); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Str("peer", remotePeer.String()).Msg("publish refused")
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-05 15:22:02 +01:00
|
|
|
for {
|
|
|
|
|
var rec PeerRecord
|
|
|
|
|
if err := json.NewDecoder(s).Decode(&rec); err != nil {
|
|
|
|
|
logger.Err(err)
|
|
|
|
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) ||
|
|
|
|
|
strings.Contains(err.Error(), "reset") ||
|
|
|
|
|
strings.Contains(err.Error(), "closed") ||
|
|
|
|
|
strings.Contains(err.Error(), "too many connections") {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, err := rec.Verify(); err != nil {
|
2026-03-11 16:28:15 +01:00
|
|
|
ix.behavior.RecordBadSignature(remotePeer)
|
|
|
|
|
logger.Warn().Err(err).Str("peer", remotePeer.String()).Msg("bad signature on publish")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := ix.behavior.CheckIdentity(remotePeer, rec.DID); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Msg("identity mismatch on publish")
|
|
|
|
|
s.Reset()
|
2026-03-05 15:22:02 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if rec.PeerID == "" || rec.ExpiryDate.Before(time.Now().UTC()) {
|
|
|
|
|
logger.Err(errors.New(rec.PeerID + " is expired."))
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-11 16:28:15 +01:00
|
|
|
pid, err := lpp.Decode(rec.PeerID)
|
2026-03-05 15:22:02 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-30 16:57:36 +01:00
|
|
|
|
2026-03-05 15:22:02 +01:00
|
|
|
ix.StreamMU.Lock()
|
|
|
|
|
defer ix.StreamMU.Unlock()
|
|
|
|
|
if ix.StreamRecords[common.ProtocolHeartbeat] == nil {
|
2026-03-11 16:28:15 +01:00
|
|
|
ix.StreamRecords[common.ProtocolHeartbeat] = map[lpp.ID]*common.StreamRecord[PeerRecord]{}
|
2026-03-05 15:22:02 +01:00
|
|
|
}
|
|
|
|
|
streams := ix.StreamRecords[common.ProtocolHeartbeat]
|
|
|
|
|
if srec, ok := streams[pid]; ok {
|
|
|
|
|
srec.DID = rec.DID
|
|
|
|
|
srec.Record = rec
|
|
|
|
|
srec.HeartbeatStream.UptimeTracker.LastSeen = time.Now().UTC()
|
|
|
|
|
}
|
2026-02-03 15:25:15 +01:00
|
|
|
|
2026-03-05 15:22:02 +01:00
|
|
|
key := ix.genKey(rec.DID)
|
|
|
|
|
data, err := json.Marshal(rec)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Err(err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
if err := ix.DHT.PutValue(ctx, key, data); err != nil {
|
|
|
|
|
logger.Err(err)
|
|
|
|
|
cancel()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-03 16:38:24 +01:00
|
|
|
cancel()
|
2026-02-09 13:28:00 +01:00
|
|
|
|
2026-03-05 15:22:02 +01:00
|
|
|
// Secondary index: /pid/<peerID> → DID, so peers can resolve by libp2p PeerID.
|
|
|
|
|
if rec.PeerID != "" {
|
2026-03-11 19:29:39 +01:00
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
if err := ix.DHT.PutValue(ctx2, ix.genPIDKey(rec.PeerID), []byte(rec.DID)); err != nil {
|
2026-03-05 15:22:02 +01:00
|
|
|
logger.Err(err).Str("pid", rec.PeerID).Msg("indexer: failed to write pid index")
|
|
|
|
|
}
|
2026-03-11 19:29:39 +01:00
|
|
|
cancel2()
|
2026-02-02 12:14:01 +01:00
|
|
|
}
|
2026-03-05 15:22:02 +01:00
|
|
|
return
|
2026-02-02 12:14:01 +01:00
|
|
|
}
|
2026-01-30 16:57:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ix *IndexerService) handleNodeGet(s network.Stream) {
|
|
|
|
|
defer s.Close()
|
2026-02-03 15:25:15 +01:00
|
|
|
logger := oclib.GetLogger()
|
2026-03-11 16:28:15 +01:00
|
|
|
remotePeer := s.Conn().RemotePeer()
|
2026-03-17 11:57:22 +01:00
|
|
|
if !ix.isPeerKnown(remotePeer) {
|
|
|
|
|
logger.Warn().Str("peer", remotePeer.String()).Msg("[get] unknown peer, rejecting stream")
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-11 16:28:15 +01:00
|
|
|
if err := ix.behavior.RecordGet(remotePeer); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Str("peer", remotePeer.String()).Msg("get refused")
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-05 15:22:02 +01:00
|
|
|
for {
|
|
|
|
|
var req GetValue
|
|
|
|
|
if err := json.NewDecoder(s).Decode(&req); err != nil {
|
|
|
|
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) ||
|
|
|
|
|
strings.Contains(err.Error(), "reset") ||
|
|
|
|
|
strings.Contains(err.Error(), "closed") ||
|
|
|
|
|
strings.Contains(err.Error(), "too many connections") {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
logger.Err(err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-01-30 16:57:36 +01:00
|
|
|
|
2026-03-05 15:22:02 +01:00
|
|
|
resp := GetResponse{Found: false, Records: map[string]PeerRecord{}}
|
2026-03-03 16:38:24 +01:00
|
|
|
|
2026-03-11 19:29:39 +01:00
|
|
|
// Resolve DID key: by PeerID (secondary /pid/ index) or direct DID key.
|
|
|
|
|
var key string
|
|
|
|
|
if req.PeerID != "" {
|
2026-03-05 15:22:02 +01:00
|
|
|
pidCtx, pidCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
2026-03-11 19:29:39 +01:00
|
|
|
did, err := ix.DHT.GetValue(pidCtx, ix.genPIDKey(req.PeerID))
|
2026-03-05 15:22:02 +01:00
|
|
|
pidCancel()
|
2026-03-11 19:29:39 +01:00
|
|
|
if err == nil {
|
|
|
|
|
key = string(did)
|
|
|
|
|
}
|
2026-03-05 15:22:02 +01:00
|
|
|
} else {
|
2026-03-11 19:29:39 +01:00
|
|
|
key = req.Key
|
2026-02-04 11:35:19 +01:00
|
|
|
}
|
2026-02-18 13:29:50 +01:00
|
|
|
|
2026-03-11 19:29:39 +01:00
|
|
|
if key != "" {
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
c, err := ix.DHT.GetValue(ctx, ix.genKey(key))
|
|
|
|
|
cancel()
|
2026-04-08 10:04:41 +02:00
|
|
|
if err == nil && !isTombstone(c) {
|
2026-03-11 19:29:39 +01:00
|
|
|
var rec PeerRecord
|
|
|
|
|
if json.Unmarshal(c, &rec) == nil {
|
|
|
|
|
resp.Records[rec.PeerID] = rec
|
2026-03-03 16:38:24 +01:00
|
|
|
}
|
2026-04-08 10:04:41 +02:00
|
|
|
} else if err != nil {
|
2026-03-11 19:29:39 +01:00
|
|
|
logger.Err(err).Msg("Failed to fetch PeerRecord from DHT " + key)
|
2026-02-03 15:25:15 +01:00
|
|
|
}
|
2026-02-02 12:14:01 +01:00
|
|
|
}
|
2026-03-03 16:38:24 +01:00
|
|
|
|
2026-03-05 15:22:02 +01:00
|
|
|
resp.Found = len(resp.Records) > 0
|
|
|
|
|
_ = json.NewEncoder(s).Encode(resp)
|
|
|
|
|
break
|
|
|
|
|
}
|
2026-03-03 16:38:24 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-08 10:04:41 +02:00
|
|
|
// handleNodeDelete processes a signed delete (tombstone) request from a peer.
|
|
|
|
|
// It verifies that the request is:
|
|
|
|
|
// - marked as a tombstone
|
|
|
|
|
// - recent (within 5 minutes, preventing replay attacks)
|
|
|
|
|
// - sent by the actual peer whose record is being deleted (PeerID == remotePeer)
|
|
|
|
|
// - signed by the matching private key
|
|
|
|
|
//
|
|
|
|
|
// On success it stores the tombstone in the DHT, evicts the peer from the local
|
|
|
|
|
// stream records, and marks the DID in deletedDIDs so AfterHeartbeat cannot
|
|
|
|
|
// accidentally republish the record during the tombstoneTTL window.
|
|
|
|
|
func (ix *IndexerService) handleNodeDelete(s network.Stream) {
|
|
|
|
|
defer s.Close()
|
|
|
|
|
logger := oclib.GetLogger()
|
|
|
|
|
remotePeer := s.Conn().RemotePeer()
|
|
|
|
|
s.SetDeadline(time.Now().Add(10 * time.Second))
|
|
|
|
|
|
|
|
|
|
var ts TombstoneRecord
|
|
|
|
|
if err := json.NewDecoder(s).Decode(&ts); err != nil || !ts.Tombstone {
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if ts.PeerID == "" || ts.DID == "" {
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if time.Since(ts.DeletedAt) > 5*time.Minute {
|
|
|
|
|
logger.Warn().Str("peer", remotePeer.String()).Msg("[delete] stale tombstone rejected")
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if ts.PeerID != remotePeer.String() {
|
|
|
|
|
logger.Warn().Str("peer", remotePeer.String()).Msg("[delete] tombstone PeerID mismatch")
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, err := ts.Verify(); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Str("peer", remotePeer.String()).Msg("[delete] invalid tombstone signature")
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark DID as deleted in-memory before writing to DHT so AfterHeartbeat
|
|
|
|
|
// cannot win a race and republish the live record on top of the tombstone.
|
|
|
|
|
ix.deletedDIDsMu.Lock()
|
|
|
|
|
ix.deletedDIDs[ts.DID] = ts.DeletedAt
|
|
|
|
|
ix.deletedDIDsMu.Unlock()
|
|
|
|
|
|
|
|
|
|
data, _ := json.Marshal(ts)
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
if err := ix.DHT.PutValue(ctx, ix.genKey(ts.DID), data); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Str("did", ts.DID).Msg("[delete] DHT write tombstone failed")
|
|
|
|
|
}
|
|
|
|
|
cancel()
|
|
|
|
|
|
|
|
|
|
// Invalidate the /pid/ secondary index so isPeerKnown returns false quickly.
|
|
|
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
if err := ix.DHT.PutValue(ctx2, ix.genPIDKey(ts.PeerID), []byte("")); err != nil {
|
|
|
|
|
logger.Warn().Err(err).Str("pid", ts.PeerID).Msg("[delete] DHT clear pid failed")
|
|
|
|
|
}
|
|
|
|
|
cancel2()
|
|
|
|
|
|
|
|
|
|
// Evict from active stream records.
|
|
|
|
|
if pid, err := lpp.Decode(ts.PeerID); err == nil {
|
|
|
|
|
ix.StreamMU.Lock()
|
|
|
|
|
delete(ix.StreamRecords[common.ProtocolHeartbeat], pid)
|
|
|
|
|
ix.StreamMU.Unlock()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.Info().Str("did", ts.DID).Str("peer", ts.PeerID).Msg("[delete] tombstone stored, peer evicted")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleIndirectProbe is the SWIM inter-indexer probe handler.
|
|
|
|
|
// A node opens this stream toward a live indexer to ask: "can you reach peer X?"
|
|
|
|
|
// The indexer attempts a ProtocolBandwidthProbe to X and reports back.
|
|
|
|
|
// This is the only protocol that indexers use to communicate with each other;
|
|
|
|
|
// no persistent inter-indexer connections are maintained.
|
|
|
|
|
func (ix *IndexerService) handleIndirectProbe(s network.Stream) {
|
|
|
|
|
defer s.Close()
|
|
|
|
|
s.SetDeadline(time.Now().Add(10 * time.Second))
|
|
|
|
|
|
|
|
|
|
var req common.IndirectProbeRequest
|
|
|
|
|
if err := json.NewDecoder(s).Decode(&req); err != nil {
|
|
|
|
|
s.Reset()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
respond := func(reachable bool, latencyMs int64) {
|
|
|
|
|
json.NewEncoder(s).Encode(common.IndirectProbeResponse{
|
|
|
|
|
Reachable: reachable,
|
|
|
|
|
LatencyMs: latencyMs,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Connect to target if not already connected.
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if ix.Host.Network().Connectedness(req.Target.ID) != network.Connected {
|
|
|
|
|
if err := ix.Host.Connect(ctx, req.Target); err != nil {
|
|
|
|
|
respond(false, 0)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Open a bandwidth probe stream — already registered on all nodes/indexers.
|
|
|
|
|
start := time.Now()
|
|
|
|
|
ps, err := ix.Host.NewStream(ctx, req.Target.ID, common.ProtocolBandwidthProbe)
|
|
|
|
|
if err != nil {
|
|
|
|
|
respond(false, 0)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer ps.Reset()
|
|
|
|
|
ps.SetDeadline(time.Now().Add(3 * time.Second))
|
|
|
|
|
ps.Write([]byte("ping"))
|
|
|
|
|
buf := make([]byte, 4)
|
|
|
|
|
_, err = ps.Read(buf)
|
|
|
|
|
latency := time.Since(start).Milliseconds()
|
|
|
|
|
respond(err == nil, latency)
|
|
|
|
|
}
|