package indexer import ( "errors" "sync" "time" "oc-discovery/conf" pp "github.com/libp2p/go-libp2p/core/peer" ) // ── defaults ────────────────────────────────────────────────────────────────── const ( defaultMaxConnPerWindow = 20 defaultConnWindowSecs = 30 defaultMaxHBPerMinute = 5 defaultMaxPublishPerMin = 10 defaultMaxGetPerMin = 50 strikeThreshold = 3 banDuration = 10 * time.Minute behaviorWindowDur = 60 * time.Second ) func cfgOr(v, def int) int { if v > 0 { return v } return def } // ── ConnectionRateGuard ─────────────────────────────────────────────────────── // ConnectionRateGuard limits the number of NEW incoming connections accepted // within a sliding time window. It protects public indexers against coordinated // registration floods (Sybil bursts). type ConnectionRateGuard struct { mu sync.Mutex window []time.Time maxInWindow int windowDur time.Duration } func newConnectionRateGuard() *ConnectionRateGuard { cfg := conf.GetConfig() return &ConnectionRateGuard{ maxInWindow: cfgOr(cfg.MaxConnPerWindow, defaultMaxConnPerWindow), windowDur: time.Duration(cfgOr(cfg.ConnWindowSecs, defaultConnWindowSecs)) * time.Second, } } // Allow returns true if a new connection may be accepted. // The internal window is pruned on each call so memory stays bounded. func (g *ConnectionRateGuard) Allow() bool { g.mu.Lock() defer g.mu.Unlock() now := time.Now() cutoff := now.Add(-g.windowDur) i := 0 for i < len(g.window) && g.window[i].Before(cutoff) { i++ } g.window = g.window[i:] if len(g.window) >= g.maxInWindow { return false } g.window = append(g.window, now) return true } // ── per-node state ──────────────────────────────────────────────────────────── type nodeBehavior struct { mu sync.Mutex knownDID string hbTimes []time.Time pubTimes []time.Time getTimes []time.Time strikes int bannedUntil time.Time } func (nb *nodeBehavior) isBanned() bool { return time.Now().UTC().Before(nb.bannedUntil) } func (nb *nodeBehavior) strike(n int) { nb.strikes += n if nb.strikes >= strikeThreshold { nb.bannedUntil = time.Now().Add(banDuration) } } func pruneWindow(ts []time.Time, dur time.Duration) []time.Time { cutoff := time.Now().Add(-dur) i := 0 for i < len(ts) && ts[i].Before(cutoff) { i++ } return ts[i:] } // recordInWindow appends now to the window slice and returns false (+ adds a // strike) when the count exceeds max. func (nb *nodeBehavior) recordInWindow(ts *[]time.Time, max int) bool { *ts = pruneWindow(*ts, behaviorWindowDur) if len(*ts) >= max { nb.strike(1) return false } *ts = append(*ts, time.Now()) return true } // ── NodeBehaviorTracker ─────────────────────────────────────────────────────── // NodeBehaviorTracker is the indexer-side per-node compliance monitor. // It is entirely local: no state is shared with other indexers. type NodeBehaviorTracker struct { mu sync.RWMutex nodes map[pp.ID]*nodeBehavior maxHB int maxPub int maxGet int } func newNodeBehaviorTracker() *NodeBehaviorTracker { cfg := conf.GetConfig() return &NodeBehaviorTracker{ nodes: make(map[pp.ID]*nodeBehavior), maxHB: cfgOr(cfg.MaxHBPerMinute, defaultMaxHBPerMinute), maxPub: cfgOr(cfg.MaxPublishPerMinute, defaultMaxPublishPerMin), maxGet: cfgOr(cfg.MaxGetPerMinute, defaultMaxGetPerMin), } } func (t *NodeBehaviorTracker) get(pid pp.ID) *nodeBehavior { t.mu.RLock() nb := t.nodes[pid] t.mu.RUnlock() if nb != nil { return nb } t.mu.Lock() defer t.mu.Unlock() if nb = t.nodes[pid]; nb == nil { nb = &nodeBehavior{} t.nodes[pid] = nb } return nb } // IsBanned returns true when the peer is in an active ban period. func (t *NodeBehaviorTracker) IsBanned(pid pp.ID) bool { nb := t.get(pid) nb.mu.Lock() defer nb.mu.Unlock() return nb.isBanned() } // RecordHeartbeat checks heartbeat cadence. Returns an error if the peer is // flooding (too many heartbeats in the sliding window). func (t *NodeBehaviorTracker) RecordHeartbeat(pid pp.ID) error { nb := t.get(pid) nb.mu.Lock() defer nb.mu.Unlock() if nb.isBanned() { return errors.New("peer is banned") } if !nb.recordInWindow(&nb.hbTimes, t.maxHB) { return errors.New("heartbeat flood detected") } return nil } // CheckIdentity verifies that the DID associated with a PeerID never changes. // A DID change is a strong signal of identity spoofing. func (t *NodeBehaviorTracker) CheckIdentity(pid pp.ID, did string) error { if did == "" { return nil } nb := t.get(pid) nb.mu.Lock() defer nb.mu.Unlock() if nb.knownDID == "" { nb.knownDID = did return nil } if nb.knownDID != did { nb.strike(2) // identity change is severe return errors.New("DID mismatch for peer " + pid.String()) } return nil } // RecordBadSignature registers a cryptographic verification failure. // A single bad signature is worth 2 strikes (near-immediate ban). func (t *NodeBehaviorTracker) RecordBadSignature(pid pp.ID) { nb := t.get(pid) nb.mu.Lock() defer nb.mu.Unlock() nb.strike(2) } // RecordPublish checks publish volume. Returns an error if the peer is // sending too many publish requests. func (t *NodeBehaviorTracker) RecordPublish(pid pp.ID) error { nb := t.get(pid) nb.mu.Lock() defer nb.mu.Unlock() if nb.isBanned() { return errors.New("peer is banned") } if !nb.recordInWindow(&nb.pubTimes, t.maxPub) { return errors.New("publish volume exceeded") } return nil } // RecordGet checks get volume. Returns an error if the peer is enumerating // the DHT at an abnormal rate. func (t *NodeBehaviorTracker) RecordGet(pid pp.ID) error { nb := t.get(pid) nb.mu.Lock() defer nb.mu.Unlock() if nb.isBanned() { return errors.New("peer is banned") } if !nb.recordInWindow(&nb.getTimes, t.maxGet) { return errors.New("get volume exceeded") } return nil } // Cleanup removes the behavior entry for a peer if it is not currently banned. // Called when the peer is evicted from StreamRecords by the GC. func (t *NodeBehaviorTracker) Cleanup(pid pp.ID) { t.mu.RLock() nb := t.nodes[pid] t.mu.RUnlock() if nb == nil { return } nb.mu.Lock() banned := nb.isBanned() nb.mu.Unlock() if !banned { t.mu.Lock() delete(t.nodes, pid) t.mu.Unlock() } }