Files and the journal, patterns with one <IP>, bounties within a window, bans in a Housecarl ban list with repeat offenders' bans growing; presets for sshd, jarl, housecarl, steward, bind-xfr, postfix-sasl, dovecot, nginx-auth and housecarl-drops; guard run/check/test/status/bans/ban/unban; a Debian package with a sandboxed unit; an end-to-end test with Housecarl. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
2.8 KiB
Go
121 lines
2.8 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
|
|
}
|
|
|
|
// 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()
|
|
}
|