255 lines
6.6 KiB
Go
255 lines
6.6 KiB
Go
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()
|
|
}
|
|
}
|