// Package bounty keeps each address's bounty in a watch: its matches // within the watch's window. An address whose bounty reaches the watch's // hits is banned, and starts again from nothing. package bounty import ( "net/netip" "sort" "sync" "time" ) // MaxTracked is the most addresses a watch keeps a bounty for; past it, // the ones seen longest ago are forgotten. const MaxTracked = 100000 // Tracker counts one watch's matches per address (or IPv6 prefix). type Tracker struct { Hits int Within time.Duration mu sync.Mutex seen map[netip.Prefix][]time.Time } // New makes a Tracker. func New(hits int, within time.Duration) *Tracker { return &Tracker{Hits: hits, Within: within, seen: map[netip.Prefix][]time.Time{}} } // Add counts a match of p at a time. It returns p's bounty, and whether // it reached the hits (p's bounty is then cleared). func (t *Tracker) Add(p netip.Prefix, at time.Time) (int, bool) { t.mu.Lock() defer t.mu.Unlock() ts := keep(t.seen[p], at.Add(-t.Within)) ts = append(ts, at) if len(ts) >= t.Hits { delete(t.seen, p) return len(ts), true } t.seen[p] = ts if len(t.seen) > MaxTracked { t.prune(at) } return len(ts), false } // Set changes the hits and window (a reload); the bounties stay. func (t *Tracker) Set(hits int, within time.Duration) { t.mu.Lock() t.Hits, t.Within = hits, within t.mu.Unlock() } // keep drops the times before from (times are in order). func keep(ts []time.Time, from time.Time) []time.Time { i := 0 for i < len(ts) && ts[i].Before(from) { i++ } return ts[i:] } // prune forgets what is out of the window, then, if still too many, the // addresses seen longest ago. func (t *Tracker) prune(now time.Time) { for p, ts := range t.seen { if ts = keep(ts, now.Add(-t.Within)); len(ts) == 0 { delete(t.seen, p) } else { t.seen[p] = ts } } if len(t.seen) <= MaxTracked { return } type last struct { p netip.Prefix t time.Time } all := make([]last, 0, len(t.seen)) for p, ts := range t.seen { all = append(all, last{p, ts[len(ts)-1]}) } sort.Slice(all, func(i, j int) bool { return all[i].t.Before(all[j].t) }) for _, x := range all[:len(all)-MaxTracked*9/10] { delete(t.seen, x.p) } } // Bounty is an address's bounty. type Bounty struct { Address string `json:"address"` Bounty int `json:"bounty"` Last time.Time `json:"last"` } // Top returns the n highest bounties now (within the window), highest // first. func (t *Tracker) Top(n int, now time.Time) []Bounty { t.mu.Lock() defer t.mu.Unlock() var out []Bounty for p, ts := range t.seen { if ts = keep(ts, now.Add(-t.Within)); len(ts) > 0 { out = append(out, Bounty{Address: address(p), Bounty: len(ts), Last: ts[len(ts)-1]}) } } sort.Slice(out, func(i, j int) bool { if out[i].Bounty != out[j].Bounty { return out[i].Bounty > out[j].Bounty } return out[i].Last.After(out[j].Last) }) if len(out) > n { out = out[:n] } return out } func address(p netip.Prefix) string { if p.IsSingleIP() { return p.Addr().String() } return p.String() }