Redo the banning logic, ban.Service

This commit is contained in:
binwiederhier
2026-07-20 21:34:47 +02:00
parent 469d263a5c
commit c674985699
16 changed files with 779 additions and 417 deletions
+25 -96
View File
@@ -7,11 +7,10 @@ import (
"io/fs"
"net/netip"
"reflect"
"strconv"
"strings"
"text/template"
"time"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/user"
)
@@ -46,15 +45,25 @@ const (
// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights)
const (
DefaultBanWindow = time.Minute
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a visitor is banned
DefaultBanWindow = 10 * time.Minute
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned
)
// DefaultBanWeights defines the per-code strike weights used when the ban-feed is enabled but no
// explicit ban-weights are configured. Format is "KEY:WEIGHT" (see ParseBanWeights). The auth-failure
// flood is weighted heavily so it bans fast, the legit quota 429s are exempt (weight 0), and every
// other rejection costs one strike via the "*" fallback.
var DefaultBanWeights = []string{"42909:10", "42908:0", "42903:0", "42905:0", "42910:0", "*:1"}
// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast,
// legit quota codes are exempt (weight 0), and everything else defaults to weight 1 (no "*"; see WeightFor).
var DefaultBanWeights = []string{
banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast
banWeight(errHTTPTooManyRequestsLimitMessages, 0), // daily message quota -> legit, exempt
banWeight(errHTTPTooManyRequestsLimitSubscriptions, 0), // too many subscriptions -> legit, exempt
banWeight(errHTTPTooManyRequestsLimitAttachmentBandwidth, 0), // daily attachment bandwidth -> legit, exempt
banWeight(errHTTPTooManyRequestsLimitCalls, 0), // daily phone-call quota -> legit, exempt
}
// banWeight formats a "CODE:WEIGHT" ban-feed default from an ntfy error, so the codes stay in sync with
// the errHTTP definitions instead of being duplicated as string literals.
func banWeight(err *errHTTP, weight int) string {
return fmt.Sprintf("%d:%d", err.Code, weight)
}
// Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message
@@ -211,13 +220,13 @@ type Config struct {
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
BanThreshold int // Abuse ban-feed: weighted strikes per window before a visitor is banned
BanWeights map[string]int // Abuse ban-feed: normalized code matcher -> strike weight (see ParseBanWeights, weightFor)
BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
BanThreshold int // Abuse ban-feed: weighted strikes per window before a prefix is banned
BanWeights ban.Weights // Abuse ban-feed: code matcher -> strike weight (see ban.ParseWeights, ban.Weights.WeightFor)
BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
}
// NewConfig instantiates a default new server config
@@ -345,83 +354,3 @@ func (c *Config) Hash() string {
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
}
// ParseBanWeights turns a list like ["42909:10","403:2","42908:0","*:1"] into a map of normalized
// matcher key -> strike weight for the abuse ban-feed's single weighted bucket. A key may be an exact
// ntfy code ("42909"), a prefix family ("429*"), or "*". A bare 3-digit HTTP status ("403") is a
// shorthand normalized to its family ("403*"), so operators can weight a whole status at once. Weights
// must be integers >= 0; a weight of 0 is valid and means the code is exempt (never contributes to a
// ban), which lets a "*" catch-all coexist with carved-out legit-quota codes. A malformed entry is
// rejected so misconfiguration surfaces at startup rather than silently disabling bans.
func ParseBanWeights(entries []string) (map[string]int, error) {
out := make(map[string]int, len(entries))
for _, entry := range entries {
key, weightStr, ok := strings.Cut(entry, ":")
if !ok {
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
}
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
if err != nil || weight < 0 {
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
}
key = strings.TrimSpace(key)
if !validBanWeightKey(key) {
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
}
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
if len(key) == 3 && isAllDigits(key) {
key += "*"
}
out[key] = weight
}
return out, nil
}
// validBanWeightKey reports whether key is a legal ban-weight matcher: "*", an all-digits code,
// or an all-digits prefix followed by "*".
func validBanWeightKey(key string) bool {
if key == "*" {
return true
}
digits := strings.TrimSuffix(key, "*")
return digits != "" && isAllDigits(digits)
}
func isAllDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}
// weightFor returns the strike weight for a rejection's 5-digit ntfy code using the ban-weights
// matcher, longest-match-wins. An exact key ("42909") matches with length len(code); a family key
// ("429*") matches a code with that prefix, with length equal to the prefix; "*" matches everything
// with length 0. The weight of the longest match is returned, or 0 if no key matches (which the
// caller treats as "no strike").
func (c *Config) weightFor(ntfyCode int) int {
code := strconv.Itoa(ntfyCode)
weight, bestLen, matched := 0, -1, false
for key, w := range c.BanWeights {
matchLen := -1
switch {
case key == "*":
matchLen = 0
case strings.HasSuffix(key, "*"):
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
matchLen = len(prefix)
}
case key == code:
matchLen = len(code)
}
if matchLen > bestLen {
weight, bestLen, matched = w, matchLen, true
}
}
if !matched {
return 0
}
return weight
}