Files
abrown38andClaude Opus 5.5 ecf4129877 Reload guard.yaml without losing the bounties
systemctl reload guard, guard reload or SIGHUP reads the configuration
again: a watch keeps its bounties and counts under the same name and
takes its new settings at once; its source starts again only if it
changed; watches gone stop, new ones start; a file with a mistake is
refused. The Housecarl connection, state, socket and log wait for a
restart, and guard reload says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:32:20 +00:00

128 lines
3.0 KiB
Go

// 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()
}