diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 863c0996..b9d5fcf1 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier), -or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly +or email (contact@mail.ntfy.sh). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/ban/service.go b/ban/service.go new file mode 100644 index 00000000..2390017f --- /dev/null +++ b/ban/service.go @@ -0,0 +1,185 @@ +// Package ban implements the abuse ban-feed: it tracks per-prefix weighted "strikes" from rejected +// requests and appends breaching prefixes to a ban file that fail2ban tails. Keying by prefix (not +// by visitor) makes the accounting match the unit fail2ban bans, even for shared account visitors. +package ban + +import ( + "fmt" + "net/netip" + "os" + "sync" + "time" + + "golang.org/x/time/rate" + "heckel.io/ntfy/v2/log" +) + +const ( + tag = "ban" + pruneInterval = 10 * time.Minute + writeInterval = 3 * time.Second +) + +// Config is the Service's config, kept separate from server.Config to avoid an import cycle. +type Config struct { + File string // Ban file that fail2ban tails (must be non-empty; the caller decides whether the feature is enabled) + Window time.Duration // Rolling window over which weighted strikes are counted + Threshold int // Weighted strikes per Window before a prefix is banned + Weights Weights // Code matcher -> strike weight (0 = exempt) + PrefixBitsIPv4 int // Mask width for the ban unit, e.g. 32 (matches rate-limiting granularity) + PrefixBitsIPv6 int // Mask width for the ban unit, e.g. 64 +} + +// tracker is the per-prefix strike state: a weighted breach detector plus timestamps for pruning and throttling. +type tracker struct { + limiter *rate.Limiter + seen time.Time // Last strike, for pruning idle prefixes + emitted time.Time // Last ban-line write for this prefix, throttles re-emits to once per Window +} + +// Service owns the ban-feed: per-prefix strike accounting, buffered file writes, and idle-prefix +// pruning. The caller owns the enable/disable decision -- only construct a Service when the feature +// is on (see server.New, which builds one only when a ban file is configured). +type Service struct { + conf *Config + mu sync.Mutex // Guards trackers and pending + trackers map[netip.Prefix]*tracker + pending []string // Formatted ban lines buffered by Record, flushed to the ban file by runWriteLoop + writeDone chan struct{} // Closed when runWriteLoop exits after its final flush + closeChan chan struct{} + closeOnce sync.Once +} + +// NewService builds a Service and starts its background loops. The caller must only call it when the +// feature is enabled (conf non-nil, File non-empty); the Service does not model a disabled state. +func NewService(conf *Config) *Service { + s := &Service{ + conf: conf, + trackers: make(map[netip.Prefix]*tracker), + closeChan: make(chan struct{}), + writeDone: make(chan struct{}), + } + go s.runPruneLoop() + go s.runWriteLoop() + return s +} + +// Record counts one rejection against the IP's prefix bucket and, on breach, buffers a ban line +// (throttled to once per Window per prefix). No-ops for a non-4xx/5xx status or a zero-weight code. +func (s *Service) Record(ip netip.Addr, httpCode, errorCode int) { + if httpCode < 400 { + return + } + weight := s.conf.Weights.WeightFor(errorCode) + if weight == 0 { + return // Weight 0: exempt, no strike + } + prefix := s.prefix(ip) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + t := s.trackers[prefix] + if t == nil { + t = &tracker{limiter: rate.NewLimiter(rate.Limit(float64(s.conf.Threshold)/s.conf.Window.Seconds()), s.conf.Threshold)} + s.trackers[prefix] = t + } + t.seen = now + if t.limiter.AllowN(now, weight) { + return // Within the strike budget, no breach + } + if !t.emitted.IsZero() && now.Sub(t.emitted) < s.conf.Window { + return // Already emitted this prefix within the window (one ban line per prefix per window) + } + t.emitted = now + s.pending = append(s.pending, formatBanLine(now, ip, prefix, httpCode, errorCode)) +} + +// prefix masks ip to the ban unit (PrefixBitsIPv4/IPv6) -- what fail2ban bans, e.g. a whole /64. +func (s *Service) prefix(ip netip.Addr) netip.Prefix { + if ip.Is4() { + return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv4).Masked() + } + return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv6).Masked() +} + +// formatBanLine builds the " " line the fail2ban filter +// parses. The timestamp is captured at breach time, not flush time. +func formatBanLine(t time.Time, ip netip.Addr, prefix netip.Prefix, httpCode, errorCode int) string { + return fmt.Sprintf("%s %s %s %d %d\n", t.UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, errorCode) +} + +// runPruneLoop prunes idle prefixes until Close. +func (s *Service) runPruneLoop() { + ticker := time.NewTicker(pruneInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.prune() + case <-s.closeChan: + return + } + } +} + +// runWriteLoop flushes buffered ban lines every writeInterval, plus a final flush on Close. +func (s *Service) runWriteLoop() { + defer close(s.writeDone) + ticker := time.NewTicker(writeInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.flush() + case <-s.closeChan: + s.flush() + return + } + } +} + +// flush appends the buffered lines to the file in one open/write. Best-effort: a batch is dropped on +// error. Concurrent calls are safe -- pending is drained under mu, so only one flush writes a batch. +func (s *Service) flush() { + s.mu.Lock() + lines := s.pending + s.pending = nil + s.mu.Unlock() + if len(lines) == 0 { + return + } + f, err := os.OpenFile(s.conf.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Tag(tag).Err(err).Warn("Cannot open ban file %s, dropped %d ban(s)", s.conf.File, len(lines)) + return + } + defer f.Close() + for i, line := range lines { + if _, err := f.WriteString(line); err != nil { + log.Tag(tag).Err(err).Warn("Cannot write to ban file %s, dropped %d ban(s)", s.conf.File, len(lines)-i) + return + } + } +} + +// prune drops prefixes idle for a full Window -- their bucket has refilled, so forgetting them is a +// no-op that bounds memory under a flood of distinct IPs. +func (s *Service) prune() { + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + for prefix, t := range s.trackers { + if now.Sub(t.seen) >= s.conf.Window { + delete(s.trackers, prefix) + } + } +} + +// Close stops the loops and blocks until the final flush completes. Idempotent. +func (s *Service) Close() { + s.closeOnce.Do(func() { + close(s.closeChan) + <-s.writeDone + }) +} diff --git a/ban/service_test.go b/ban/service_test.go new file mode 100644 index 00000000..3d1ec210 --- /dev/null +++ b/ban/service_test.go @@ -0,0 +1,253 @@ +package ban + +import ( + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var testIP = netip.MustParseAddr("1.2.3.4") + +// newTestService creates a Service wired for testing, with the given ban file, weighted-bucket +// threshold, and weights, plus a 1-minute window (so the emit throttle only fires once per test). +func newTestService(t *testing.T, banFile string, threshold int, weights map[string]int) *Service { + t.Helper() + s := NewService(&Config{ + File: banFile, + Window: time.Minute, + Threshold: threshold, + Weights: weights, + PrefixBitsIPv4: 32, + PrefixBitsIPv6: 64, + }) + t.Cleanup(s.Close) + return s +} + +// flushAndRead forces a synchronous flush of the buffered bans (writes are otherwise async, on the +// runWriteLoop ticker) and returns the ban file's lines. +func flushAndRead(t *testing.T, s *Service, path string) []string { + t.Helper() + s.flush() + data, err := os.ReadFile(path) + require.NoError(t, err) + return strings.Split(strings.TrimRight(string(data), "\n"), "\n") +} + +func TestService_Record_Weight2BansAtHalfThreshold(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + // Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches. + s := newTestService(t, banFile, 10, map[string]int{"*": 2}) + for i := 0; i < 5; i++ { + s.Record(testIP, 400, 40001) + } + s.flush() + require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over + s.Record(testIP, 400, 40001) // 6th hit cannot be covered -> breach + lines := flushAndRead(t, s, banFile) + require.Len(t, lines, 1) + require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // +} + +func TestService_Record_Weight10BansFast(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + // Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches. + s := newTestService(t, banFile, 10, map[string]int{"42909": 10, "*": 1}) + s.Record(testIP, 429, 42909) + s.flush() + require.NoFileExists(t, banFile) + s.Record(testIP, 429, 42909) // 2nd hit cannot be covered -> breach + lines := flushAndRead(t, s, banFile) + require.Len(t, lines, 1) + require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909")) +} + +func TestService_Record_Weight0NeverBans(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + // The legit-quota code is exempt (weight 0), so no number of hits ever bans. + s := newTestService(t, banFile, 10, map[string]int{"42908": 0, "*": 1}) + for i := 0; i < 100; i++ { + s.Record(testIP, 429, 42908) + } + s.flush() + require.NoFileExists(t, banFile) +} + +func TestService_Record_SingleBucketNoRelaxation(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + // One shared bucket per prefix: different codes draw down the SAME budget, so mixing them creates + // no extra headroom (unlike per-code buckets, which would relax the effective limit for a mixed + // offender). + s := newTestService(t, banFile, 10, map[string]int{"403*": 2, "*": 1}) + s.Record(testIP, 403, 40301) // weight 2 -> 8 left + s.Record(testIP, 403, 40301) // weight 2 -> 6 left + s.Record(testIP, 403, 40301) // weight 2 -> 4 left + s.flush() + require.NoFileExists(t, banFile) + for i := 0; i < 4; i++ { + s.Record(testIP, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left + } + s.flush() + require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over + s.Record(testIP, 400, 40001) // one more cannot be covered -> breach + lines := flushAndRead(t, s, banFile) + require.Len(t, lines, 1) +} + +func TestService_Record_ExactLineFormat(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + s := newTestService(t, banFile, 1, map[string]int{"*": 1}) + before := time.Now().UTC().Truncate(time.Second) + for i := 0; i < 3; i++ { + s.Record(testIP, 429, 42901) + } + after := time.Now().UTC() + lines := flushAndRead(t, s, banFile) + require.Len(t, lines, 1) + parts := strings.Split(lines[0], " ") + require.Len(t, parts, 5) // " " + ts, err := time.Parse(time.RFC3339, parts[0]) + require.NoError(t, err) + require.Equal(t, time.UTC, ts.Location()) + require.False(t, ts.Before(before)) + require.False(t, ts.After(after.Add(time.Second))) + require.Equal(t, "1.2.3.4", parts[1]) // full IP + require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32) + require.Equal(t, "429", parts[3]) // HTTP status + require.Equal(t, "42901", parts[4]) // ntfy code +} + +func TestService_Record_IPv6MaskedToPrefix(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + s := newTestService(t, banFile, 1, map[string]int{"*": 1}) + ip := netip.MustParseAddr("2001:db8::abcd") + for i := 0; i < 3; i++ { + s.Record(ip, 429, 42901) + } + parts := strings.Split(flushAndRead(t, s, banFile)[0], " ") + require.Len(t, parts, 5) + require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address + require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64) +} + +func TestService_Record_PerPrefixIsolation(t *testing.T) { + // Each source prefix gets its own bucket: one IP hammering to a breach must not push a different, + // quiet IP over the edge. This is the whole point of keying by prefix instead of by visitor. + banFile := filepath.Join(t.TempDir(), "ban.log") + s := newTestService(t, banFile, 2, map[string]int{"*": 1}) + noisy := netip.MustParseAddr("1.1.1.1") + quiet := netip.MustParseAddr("2.2.2.2") + for i := 0; i < 5; i++ { + s.Record(noisy, 429, 42901) // breaches its own bucket + } + s.Record(quiet, 429, 42901) // single hit, well under threshold + lines := flushAndRead(t, s, banFile) + require.Len(t, lines, 1) // only the noisy prefix is written + require.True(t, strings.HasSuffix(lines[0], " 1.1.1.1 1.1.1.1/32 429 42901")) +} + +func TestService_Record_OncePerWindowThrottle(t *testing.T) { + // Once a prefix has been written, further breaches within the window must not re-append it, so a + // persistent offender produces exactly one line per window (mirrors the old per-visitor banEmit). + banFile := filepath.Join(t.TempDir(), "ban.log") + s := newTestService(t, banFile, 1, map[string]int{"*": 1}) + for i := 0; i < 50; i++ { + s.Record(testIP, 429, 42901) + } + lines := flushAndRead(t, s, banFile) + require.Len(t, lines, 1) +} + +func TestService_Record_BansPassedIP(t *testing.T) { + // The Service bans the exact IP passed to Record -- the caller passes the offending request's IP, + // which for an account-keyed visitor is not the visitor's stored IP. + banFile := filepath.Join(t.TempDir(), "ban.log") + s := newTestService(t, banFile, 1, map[string]int{"*": 1}) + offender := netip.MustParseAddr("5.6.7.8") + for i := 0; i < 3; i++ { + s.Record(offender, 429, 42901) + } + parts := strings.Split(flushAndRead(t, s, banFile)[0], " ") + require.Equal(t, "5.6.7.8", parts[1]) // the IP passed to Record + require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix +} + +func TestService_Record_Ignores2xx3xx(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + s := newTestService(t, banFile, 3, map[string]int{"*": 1}) + // Success and redirects must never count toward a ban, even over the threshold -- otherwise a + // legit high-volume publisher (lots of 200s) would get banned. + for i := 0; i < 20; i++ { + s.Record(testIP, 200, 20000) + s.Record(testIP, 302, 30000) + } + s.flush() + require.NoFileExists(t, banFile) + // A 4xx over the same budget still gets written. + for i := 0; i < 5; i++ { + s.Record(testIP, 400, 40001) + } + lines := flushAndRead(t, s, banFile) + require.Len(t, lines, 1) + require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) +} + +func TestService_Record_BuffersUntilFlush(t *testing.T) { + // Writes are async: a breach buffers the ban line rather than writing it synchronously on the + // request path. The line only reaches the file when runWriteLoop (or an explicit flush) runs. + banFile := filepath.Join(t.TempDir(), "ban.log") + s := newTestService(t, banFile, 1, map[string]int{"*": 1}) + for i := 0; i < 3; i++ { + s.Record(testIP, 429, 42901) + } + require.NoFileExists(t, banFile) // not written synchronously + s.mu.Lock() + require.Len(t, s.pending, 1) // one line buffered (throttled to once per window) + s.mu.Unlock() + lines := flushAndRead(t, s, banFile) + require.Len(t, lines, 1) + require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901")) +} + +func TestService_Close_FlushesPending(t *testing.T) { + // Close must flush buffered bans so nothing is lost on shutdown, and must block until it has. + banFile := filepath.Join(t.TempDir(), "ban.log") + s := NewService(&Config{File: banFile, Window: time.Minute, Threshold: 1, Weights: Weights{"*": 1}, PrefixBitsIPv4: 32, PrefixBitsIPv6: 64}) + for i := 0; i < 3; i++ { + s.Record(testIP, 429, 42901) + } + require.NoFileExists(t, banFile) // still buffered + s.Close() // blocks until the final flush completes + data, err := os.ReadFile(banFile) + require.NoError(t, err) + require.Len(t, strings.Split(strings.TrimRight(string(data), "\n"), "\n"), 1) +} + +func TestService_Prune_DropsIdlePrefixes(t *testing.T) { + // A prefix idle for a full window has a refilled bucket, so prune drops it to bound memory. An + // active prefix (seen within the window) is kept. + banFile := filepath.Join(t.TempDir(), "ban.log") + s := newTestService(t, banFile, 10, map[string]int{"*": 1}) + idle := netip.MustParseAddr("9.9.9.9") + s.Record(idle, 400, 40001) + s.Record(testIP, 400, 40001) + require.Len(t, s.trackers, 2) + + // Backdate the idle prefix past the window, then prune. + idlePrefix := s.prefix(idle) + s.mu.Lock() + s.trackers[idlePrefix].seen = time.Now().Add(-2 * time.Minute) + s.mu.Unlock() + s.prune() + + require.Len(t, s.trackers, 1) + _, ok := s.trackers[idlePrefix] + require.False(t, ok) // idle prefix dropped + _, ok = s.trackers[s.prefix(testIP)] + require.True(t, ok) // active prefix kept +} diff --git a/ban/weights.go b/ban/weights.go new file mode 100644 index 00000000..f28e602b --- /dev/null +++ b/ban/weights.go @@ -0,0 +1,83 @@ +package ban + +import ( + "fmt" + "strconv" + "strings" +) + +// Weights maps a matcher key to a strike weight for the abuse ban-feed (see ParseWeights, WeightFor). +type Weights map[string]int + +// ParseWeights normalizes a list like ["42909:10","403:2","*:1"] into a Weights map. A key is an +// exact ntfy code, a family ("429*"), a bare HTTP status ("403" -> "403*"), or "*"; weights are ints +// >= 0 (0 = exempt). Malformed entries are rejected so misconfiguration fails at startup. +func ParseWeights(entries []string) (Weights, error) { + out := make(Weights, 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 !validWeightKey(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 +} + +// WeightFor returns the strike weight for an ntfy error code, longest-match-wins (exact > family > "*"). +// If nothing matches (no "*" catch-all) it returns the implied default 1, so a forgotten "*" still +// bans; use "*:0" to exempt everything not explicitly weighted. +func (w Weights) WeightFor(errorCode int) int { + code := strconv.Itoa(errorCode) + weight, bestLen, matched := 0, -1, false + for key, wt := range w { + 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 = wt, matchLen, true + } + } + if !matched { + return 1 + } + return weight +} + +// validWeightKey reports whether key is a legal matcher: "*", an all-digits code, or DIGITS*. +func validWeightKey(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 != "" +} diff --git a/ban/weights_test.go b/ban/weights_test.go new file mode 100644 index 00000000..6aefda3c --- /dev/null +++ b/ban/weights_test.go @@ -0,0 +1,73 @@ +package ban + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseWeights(t *testing.T) { + // Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*". + weights, err := ParseWeights([]string{"42909:10", "403:2", "42908:0", "*:1"}) + require.NoError(t, err) + require.Equal(t, Weights{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights) + + // A bare 3-digit HTTP status normalizes to its family. + weights, err = ParseWeights([]string{"429:5"}) + require.NoError(t, err) + require.Equal(t, Weights{"429*": 5}, weights) + + // An explicit family key stays as-is. + weights, err = ParseWeights([]string{"429*:5"}) + require.NoError(t, err) + require.Equal(t, Weights{"429*": 5}, weights) + + // Weight 0 is valid and means exempt. + weights, err = ParseWeights([]string{"42908:0"}) + require.NoError(t, err) + require.Equal(t, Weights{"42908": 0}, weights) + + _, err = ParseWeights([]string{"401"}) // Missing weight + require.Error(t, err) + _, err = ParseWeights([]string{"401:-1"}) // Negative weight + require.Error(t, err) + _, err = ParseWeights([]string{"401:abc"}) // Non-integer weight + require.Error(t, err) + _, err = ParseWeights([]string{"abc:10"}) // Non-numeric key + require.Error(t, err) + _, err = ParseWeights([]string{"4*3:10"}) // Star not at the end + require.Error(t, err) +} + +func TestWeights_WeightFor(t *testing.T) { + weights, err := ParseWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"}) + require.NoError(t, err) + // Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*". + require.Equal(t, 0, weights.WeightFor(42908)) + require.Equal(t, 10, weights.WeightFor(42909)) + require.Equal(t, 1, weights.WeightFor(42901)) + require.Equal(t, 2, weights.WeightFor(40311)) + require.Equal(t, 1, weights.WeightFor(40011)) + require.Equal(t, 1, weights.WeightFor(50312)) + // No rule matches (this config has 4*/5* but no "*"), so the implied default weight 1 applies. + require.Equal(t, 1, weights.WeightFor(30012)) +} + +func TestWeights_WeightFor_NoStarRuleImpliesWeight1(t *testing.T) { + // With no "*" rule, a code that matches nothing defaults to weight 1 (can be banned), so the + // feature can't be silently turned into a no-op by forgetting "*". Explicit codes still win. + weights, err := ParseWeights([]string{"42908:0", "42909:10"}) + require.NoError(t, err) + require.Equal(t, 0, weights.WeightFor(42908)) // explicitly exempt + require.Equal(t, 10, weights.WeightFor(42909)) // explicit + require.Equal(t, 1, weights.WeightFor(42901)) // unmatched -> implied 1 + require.Equal(t, 1, weights.WeightFor(40001)) // unmatched -> implied 1 +} + +func TestWeights_WeightFor_ExplicitStarZeroExemptsAll(t *testing.T) { + // An explicit "*:0" is the opt-out: exempt everything not otherwise weighted. + weights, err := ParseWeights([]string{"42909:10", "*:0"}) + require.NoError(t, err) + require.Equal(t, 10, weights.WeightFor(42909)) // explicit + require.Equal(t, 0, weights.WeightFor(42901)) // *:0 -> exempt everything else +} diff --git a/cmd/serve.go b/cmd/serve.go index 9712f94f..f0198d5f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -10,6 +10,7 @@ import ( "net" "net/netip" "net/url" + "path/filepath" "runtime" "strings" "text/template" @@ -17,6 +18,7 @@ import ( "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/ban" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/server" @@ -98,6 +100,10 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before an offender is banned"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}), @@ -215,6 +221,10 @@ func execServe(c *cli.Context) error { visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish") visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") + banFile := c.String("ban-file") + banWindowStr := c.String("ban-window") + banThreshold := c.Int("ban-threshold") + banWeightsRaw := c.StringSlice("ban-weights") behindProxy := c.Bool("behind-proxy") proxyForwardedHeader := c.String("proxy-forwarded-header") proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") @@ -270,6 +280,16 @@ func execServe(c *cli.Context) error { if err != nil { return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr) } + banWindow, err := util.ParseDuration(banWindowStr) + if err != nil { + return fmt.Errorf("invalid ban window: %s", banWindowStr) + } + + // Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback) + banWeights, err := ban.ParseWeights(banWeightsRaw) + if err != nil { + return err + } // Convert sizes to bytes messageSizeLimit, err := util.ParseSize(messageSizeLimitStr) @@ -372,6 +392,14 @@ func execServe(c *cli.Context) error { return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128") + } else if banFile != "" && banWindow <= 0 { + return errors.New("if ban-file is set, ban-window must be greater than zero") + } else if banFile != "" && banThreshold <= 0 { + return errors.New("if ban-file is set, ban-threshold must be greater than zero") + } else if banFile != "" && len(banWeights) == 0 { + return errors.New("if ban-file is set, ban-weights must not be empty") + } else if banFile != "" && !util.FileExists(filepath.Dir(banFile)) { + return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile)) } else if runtime.GOOS == "windows" && listenUnix != "" { return errors.New("listen-unix is not supported on Windows") } @@ -512,6 +540,10 @@ func execServe(c *cli.Context) error { conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 + conf.BanFile = banFile + conf.BanWindow = banWindow + conf.BanThreshold = banThreshold + conf.BanWeights = banWeights conf.BehindProxy = behindProxy conf.ProxyForwardedHeader = proxyForwardedHeader conf.ProxyTrustedPrefixes = trustedProxyPrefixes diff --git a/docs/config.md b/docs/config.md index 8d459f48..05d021ef 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2129,6 +2129,72 @@ chain. The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to 4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix. +#### Ban-feed +In addition to the fail2ban setup above, ntfy can detect abusive visitors itself and write their IP +addresses to a file for fail2ban to ban from. ntfy keeps a per-prefix weighted "strike" budget, and +each rejected request costs strikes based on its response code -- the ntfy error code, or its HTTP +status (see `ban-weights`) -- so different kinds of rejection can be weighted differently or exempted +entirely. When a prefix exceeds the budget, ntfy appends the offending IP address to `ban-file`. Since +every line is already a confirmed offender, the fail2ban jail can ban on first sight (`maxretry = 1`). + +- `ban-file` is the file offenders are appended to. If it is not set, the ban-feed is disabled. Its + parent directory must exist and be writable by ntfy. Be sure to rotate it (e.g. with logrotate and + `copytruncate`) so it does not grow unbounded. +- `ban-window` is the rolling window over which weighted strikes are counted, per IP prefix. +- `ban-threshold` is the number of weighted strikes per `ban-window` before a prefix is written to + `ban-file`. Each prefix has one shared budget, so it cannot be gamed by mixing error codes. +- `ban-weights` assigns a strike weight per matcher key, formatted as `KEY:WEIGHT`. A key is an exact + ntfy error code (`42909`), a code family (`429*`, `403*`, `4*`), a bare HTTP status (`403`, short for + `403*`), or `*`. The longest matching key wins. A weight of `0` exempts a code entirely (it never + counts toward a ban), useful to spare a specific code from a `*` catch-all. Heavier weights ban faster. If you do not + include a `*` rule, any code that matches nothing defaults to weight `1` (i.e. it can be banned); + set `*:0` to exempt everything that is not explicitly weighted. + +Only rejections (4xx/5xx) count towards a ban; successful requests never do. Because the budget +refills over `ban-window`, the trigger is a sustained rate: a prefix is only written out once it +exceeds `ban-threshold / ban-window` rejections per second (with the defaults, `100 / 10m` = ~0.17/s). + +Each line in `ban-file` has the format ` `, for example: + +``` +2026-01-15T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901 +2026-01-15T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909 +``` + +`` is `` masked to the rate-limiting prefix (`visitor-prefix-bits-ipv4`/`-ipv6`) -- the +same unit ntfy rate-limits by. Have the fail2ban filter capture the bare `` (the action then +applies the prefix): + +=== "server.yml" + ```yaml + ban-file: "/var/log/ntfy/ban.log" + ban-window: "10m" + ban-threshold: 100 + ban-weights: + - "42909:10" # too many auth failures -> brute force, ban fast + # everything else 4xx/5xx defaults to weight 1 + ``` + +=== "/etc/fail2ban/filter.d/ntfy-ban.conf" + ``` + [Definition] + failregex = ^\S+ \S+ \d+ \d+$ + datepattern = ^%%Y-%%m-%%dT%%H:%%M:%%S + ignoreregex = + ``` + +=== "/etc/fail2ban/jail.d/ntfy-ban.local" + ``` + [ntfy-ban] + enabled = true + filter = ntfy-ban + action = iptables-multiport[name=ntfy-ban, port="http,https", protocol=tcp] + logpath = /var/log/ntfy/ban.log + maxretry = 1 + findtime = 1m + bantime = 1h + ``` + ## IPv6 support ntfy fully supports IPv6, though there are a few things to keep in mind. @@ -2329,6 +2395,10 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). | | `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | | `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | +| `ban-file` | `NTFY_BAN_FILE` | *filename* | - | Abuse ban-feed: file confirmed abusive visitor IPs are appended to, for fail2ban to tail. Empty disables the feature. See [Banning bad actors](#banning-bad-actors-fail2ban) | +| `ban-window` | `NTFY_BAN_WINDOW` | *duration* | 10m | Abuse ban-feed: rolling window over which weighted strikes are counted, per IP prefix | +| `ban-threshold` | `NTFY_BAN_THRESHOLD` | *number* | 100 | Abuse ban-feed: weighted strikes per `ban-window` before a prefix is written to `ban-file` | +| `ban-weights` | `NTFY_BAN_WEIGHTS` | *list of KEY:WEIGHT* | `42909:10`| Abuse ban-feed: per-code strike weights (exact code, family `429*`, or `*`; longest match wins; `0` exempts). See [Banning bad actors](#banning-bad-actors-fail2ban) | | `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | | `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | | `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | diff --git a/docs/contact.md b/docs/contact.md index 2be59cb2..11dccfed 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -28,7 +28,7 @@ via the following channels: | Channel | Contact | Description | |-----------------------|-----------------------------------------------------|------------------------------------------| | **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers | -| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues | +| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh) | Inquire about billing issues | | **Discord/Matrix** | Mention your Pro status | Priority responses in community channels | Please include your ntfy.sh username when contacting support so we can verify your subscription status. @@ -37,6 +37,11 @@ Please include your ntfy.sh username when contacting support so we can verify yo If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md). +## Abuse reports + +To report spam, phishing, or other abuse of ntfy.sh, please email [abuse@mail.ntfy.sh](mailto:abuse@mail.ntfy.sh). +Please include the topic name and any relevant message details so we can investigate. + ## Other inquiries For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights diff --git a/docs/install.md b/docs/install.md index a1d34b7f..64b8098d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -34,37 +34,37 @@ as a service starting at boot time. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.tar.gz - tar zxvf ntfy_2.26.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.26.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.tar.gz + tar zxvf ntfy_2.26.3_linux_amd64.tar.gz + sudo cp -a ntfy_2.26.3_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.tar.gz - tar zxvf ntfy_2.26.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.26.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.tar.gz + tar zxvf ntfy_2.26.3_linux_armv6.tar.gz + sudo cp -a ntfy_2.26.3_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.tar.gz - tar zxvf ntfy_2.26.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.26.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.tar.gz + tar zxvf ntfy_2.26.3_linux_armv7.tar.gz + sudo cp -a ntfy_2.26.3_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.tar.gz - tar zxvf ntfy_2.26.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.26.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.tar.gz + tar zxvf ntfy_2.26.3_linux_arm64.tar.gz + sudo cp -a ntfy_2.26.3_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic === "x86_64/amd64" ```bash - sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.26.3_linux_amd64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "armv6" ```bash - sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.26.3_linux_armv6/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "armv7/armhf" ```bash - sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.26.3_linux_armv7/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "arm64" ```bash - sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.26.3_linux_arm64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` @@ -118,25 +118,25 @@ Install the ntfy server service script: === "x86_64/amd64" ```bash - sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.26.3_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "armv6" ```bash - sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.26.3_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "armv7/armhf" ```bash - sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.26.3_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "arm64" ```bash - sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.26.3_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` @@ -204,7 +204,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -212,7 +212,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -220,7 +220,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -228,7 +228,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -238,28 +238,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -301,18 +301,18 @@ pkg install go-ntfy ## macOS The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. -To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_darwin_all.tar.gz), extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz > ntfy_2.26.0_darwin_all.tar.gz -tar zxvf ntfy_2.26.0_darwin_all.tar.gz -sudo cp -a ntfy_2.26.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_darwin_all.tar.gz > ntfy_2.26.3_darwin_all.tar.gz +tar zxvf ntfy_2.26.3_darwin_all.tar.gz +sudo cp -a ntfy_2.26.3_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.26.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.26.3_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -333,7 +333,7 @@ brew install ntfy The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service. To install, you can either -* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_windows_amd64.zip), +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_windows_amd64.zip), extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. * Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy` diff --git a/docs/releases.md b/docs/releases.md index 980e17f1..1293a305 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -4,14 +4,26 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Current stable releases -| Component | Version | Release date | -|------------------|---------|---------------| -| ntfy server | v2.26.0 | July 9, 2026 | -| ntfy Android app | v1.24.0 | Mar 5, 2026 | -| ntfy iOS app | v1.7.0 | May 30, 2026 | +| Component | Version | Release date | +|------------------|---------|--------------| +| ntfy server | v2.26.3 | Jul 20, 2026 | +| ntfy Android app | v1.24.0 | Mar 5, 2026 | +| ntfy iOS app | v1.7.0 | May 30, 2026 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +### ntfy server v2.26.3 +Released July 20, 2026 + +This is a hotfix release, useful pretty much only for ntfy.sh. It was adds the ability to track abusive IPs more +efficiently, reducing the load on the IP banning services and preventing them from falling behind and leaving abusers +unbanned for too long. It works by tracking HTTP errors, and writing out a ban file that fail2ban can read and ban +offenders instantly. See [ban-feed](config.md#ban-feed) for details. + +**Features:** + +* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (`ban-file`, `ban-window`, `ban-threshold`, `ban-weights`; see [ban-feed docs](config.md#ban-feed)) + ### ntfy server v2.26.0 Released July 9, 2026 diff --git a/server/ban_test.go b/server/ban_test.go new file mode 100644 index 00000000..bd95a883 --- /dev/null +++ b/server/ban_test.go @@ -0,0 +1,55 @@ +package server + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/ban" +) + +// TestServer_BanFeed_WritesOffenderToFile is the end-to-end wiring test: a rejected request flows +// through s.handle -> the error responder -> s.ban.Record, and once the offender's prefix +// breaches, its ban line lands in the ban file (flushed on Close). +func TestServer_BanFeed_WritesOffenderToFile(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + conf := newTestConfig(t, "") + conf.BanFile = banFile + conf.BanWindow = time.Minute + conf.BanThreshold = 1 // capacity 1: the 2nd rejection breaches + conf.BanWeights = ban.Weights{"*": 1} // any 4xx counts one strike + s := newTestServer(t, conf) + require.NotNil(t, s.ban) + + // A delayed message with caching disabled is a deterministic 400 (errHTTPBadRequestDelayNoCache). + // request() sends from RemoteAddr 9.9.9.9. + reject := map[string]string{"Cache": "no", "In": "30 min"} + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/mytopic", "", reject) + require.Equal(t, 400, response.Code) + } + + // Writes are async; Close flushes the buffer. The offender's prefix must be in the feed exactly + // once (throttled to one line per window). + s.ban.Close() + data, err := os.ReadFile(banFile) + require.NoError(t, err) + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + require.Len(t, lines, 1) + require.Contains(t, lines[0], " 9.9.9.9 9.9.9.9/32 400 ") // +} + +// TestServer_BanFeed_DisabledByDefault verifies the feature is off with no ban file: s.ban is +// nil and the error path skips it (guarded), so a rejected request must not panic. +func TestServer_BanFeed_DisabledByDefault(t *testing.T) { + conf := newTestConfig(t, "") // no BanFile + s := newTestServer(t, conf) + require.Nil(t, s.ban) + + reject := map[string]string{"Cache": "no", "In": "30 min"} + response := request(t, s, "PUT", "/mytopic", "", reject) // guarded callsite, no Record + require.Equal(t, 400, response.Code) +} diff --git a/server/config.go b/server/config.go index 625e02ae..8df24da2 100644 --- a/server/config.go +++ b/server/config.go @@ -10,6 +10,7 @@ import ( "text/template" "time" + "heckel.io/ntfy/v2/ban" "heckel.io/ntfy/v2/user" ) @@ -42,6 +43,24 @@ const ( DefaultWebPushExpiryDuration = 60 * 24 * time.Hour ) +// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights) +const ( + DefaultBanWindow = 10 * time.Minute + DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned +) + +// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast, +// and everything else defaults to weight 1 (no "*" rule needed; see BanWeights.WeightFor). +var DefaultBanWeights = []string{ + banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast +} + +// 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 // - total topic limit: max number of topics overall @@ -196,9 +215,13 @@ type Config struct { WebPushStartupQueries string WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration - 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 @@ -299,6 +322,10 @@ func NewConfig() *Config { WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, + BanFile: "", + BanWindow: DefaultBanWindow, + BanThreshold: DefaultBanThreshold, + BanWeights: nil, BuildVersion: "", BuildDate: "", BuildCommit: "", diff --git a/server/server.go b/server/server.go index ba28d5ce..dca96747 100644 --- a/server/server.go +++ b/server/server.go @@ -31,6 +31,7 @@ import ( "golang.org/x/sync/errgroup" "heckel.io/ntfy/v2/action" "heckel.io/ntfy/v2/attachment" + "heckel.io/ntfy/v2/ban" "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/log" @@ -59,6 +60,7 @@ type Server struct { mailer mail.Sender topics map[string]*topic visitors map[string]*visitor // ip: or user: + ban *ban.Service // Abuse ban-feed; nil when the feature is disabled (no ban file) firebaseClient *firebaseClient twilio *twilio.Client messages int64 // Total number of messages (persisted if messageCache enabled) @@ -303,6 +305,17 @@ func New(conf *Config) (*Server, error) { } firebaseClient = newFirebaseClient(sender, auther) } + var banner *ban.Service + if conf.BanFile != "" { + banner = ban.NewService(&ban.Config{ + File: conf.BanFile, + Window: conf.BanWindow, + Threshold: conf.BanThreshold, + Weights: conf.BanWeights, + PrefixBitsIPv4: conf.VisitorPrefixBitsIPv4, + PrefixBitsIPv6: conf.VisitorPrefixBitsIPv6, + }) + } s := &Server{ config: conf, db: pool, @@ -312,6 +325,7 @@ func New(conf *Config) (*Server, error) { firebaseClient: firebaseClient, twilio: twilioClient, mailer: sender, + ban: banner, topics: topics, userManager: userManager, messages: messages, @@ -465,6 +479,9 @@ func (s *Server) Stop() { s.attachment.Close() } s.closeDatabases() + if s.ban != nil { + s.ban.Close() + } if s.closeChan != nil { close(s.closeChan) } @@ -487,7 +504,7 @@ func (s *Server) closeDatabases() { // handle is the main entry point for all HTTP requests func (s *Server) handle(w http.ResponseWriter, r *http.Request) { - v, err := s.maybeAuthenticate(r) // Note: Always returns v, even when error is returned + r, v, err := s.maybeAuthenticate(r) // Note: Always returns v (and r, with the client IP in its context), even on error if err != nil { s.handleError(w, r, v, err) return @@ -549,6 +566,11 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests w.WriteHeader(httpErr.HTTPCode) io.WriteString(w, httpErr.JSON()+"\n") + if s.ban != nil { + if ip, err := fromContext[netip.Addr](r, contextVisitorIP); err == nil { + s.ban.Record(ip, httpErr.HTTPCode, httpErr.Code) + } + } } func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { diff --git a/server/server.yml b/server/server.yml index 35ee7806..4b3acc46 100644 --- a/server/server.yml +++ b/server/server.yml @@ -370,6 +370,39 @@ # visitor-topic-creation-limit-burst: 100 # visitor-topic-creation-limit-replenish: "1m" +# Abuse ban-feed: Count HTTP response statuses per visitor and append abusive IPs to a file that +# fail2ban (or similar) can tail and ban on sight. This captures ntfy-layer rejections (e.g. ACL +# 403s and ntfy's own 429s), so fail2ban does not have to regex-parse the full access log. +# - ban-file is the file abusive IPs are appended to; leave empty to disable the feature. Its +# directory must exist and be writable by ntfy. Rotate it (e.g. logrotate, copytruncate) so it +# cannot grow unbounded. +# - ban-window is the rolling window over which weighted strikes are counted, per visitor. +# - ban-threshold is the number of weighted strikes per window before a visitor is banned. Each +# visitor has ONE strike budget; rejections draw it down, so there is no way to game it by mixing +# codes. +# - ban-weights assigns a strike weight to a matcher KEY (KEY:WEIGHT). A KEY is an exact ntfy code +# ("42909"), a prefix family ("429*"), a bare HTTP status ("403", shorthand for "403*"), or "*". +# Longest match wins. A weight of 0 exempts a code (never contributes to a ban), so the legit quota +# 429s can be carved out from a "*" catch-all. Heavier weights ban faster (auth-failure floods). +# +# Each appended line has the exact format +# " ", for example: +# 2026-07-17T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901 +# 2026-07-17T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909 +# is masked to the rate-limiting prefix (visitor-prefix-bits-ipv4/ipv6); that is the +# unit a fail2ban jail should ban, so a whole IPv6 subnet is banned as one. +# +# ban-file: "/var/log/ntfy-ban.log" +# ban-window: "1m" +# ban-threshold: 100 +# ban-weights: +# - "42909:10" # auth-failure flood: bans in ~10 +# - "42908:0" # daily message quota reached -> legit, never counts +# - "42903:0" # subscription limit -> legit +# - "42905:0" # daily bandwidth reached -> legit +# - "42910:0" # daily phone call quota reached -> legit +# - "*:1" # everything else 4xx/5xx + # Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting # - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address) # - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) diff --git a/server/server_auth.go b/server/server_auth.go index 3593b330..02690dc7 100644 --- a/server/server_auth.go +++ b/server/server_auth.go @@ -21,31 +21,35 @@ import ( // // This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so // that subsequent logging calls still have a visitor context. -func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { +func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, error) { // Read the "Authorization" header value and exit out early if it's not set ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) + // Stash the extracted client IP in the request context so downstream code (the abuse ban-feed in + // handleError) can reuse it without re-parsing headers, and so an account-keyed (tier'd) visitor -- + // whose shared visitor object has a stale v.ip -- is still attributed to the actual request IP. + r = withContext(r, map[contextKey]any{contextVisitorIP: ip}) vip := s.visitor(ip, nil) if s.userManager == nil { - return vip, nil + return r, vip, nil } header, err := readAuthHeader(r) if err != nil { - return vip, err + return r, vip, err } else if !supportedAuthHeader(header) { - return vip, nil + return r, vip, nil } // If we're trying to auth, check the rate limiter first if !vip.AuthAllowed() { - return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs! + return r, vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs! } u, err := s.authenticate(r, header) if err != nil { vip.AuthFailed() logr(r).Err(err).Debug("Authentication failed") - return vip, errHTTPUnauthorized // Always return visitor, even when error occurs! + return r, vip, errHTTPUnauthorized // Always return visitor, even when error occurs! } // Authentication with user was successful - return s.visitor(ip, u), nil + return r, s.visitor(ip, u), nil } // authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...). diff --git a/server/server_middleware.go b/server/server_middleware.go index 3e65a66a..117be70d 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -13,6 +13,7 @@ const ( contextRateVisitor contextKey = iota + 2586 contextTopic contextMatrixPushKey + contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record) ) func (s *Server) limitRequests(next handleFunc) handleFunc { diff --git a/server/server_test.go b/server/server_test.go index da65be9d..ae51b562 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2889,7 +2889,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) { r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "8.9.10.11:1234" r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty! - v, err := s.maybeAuthenticate(r) + _, v, err := s.maybeAuthenticate(r) require.Nil(t, err) require.Equal(t, "8.9.10.11", v.ip.String()) }) @@ -2903,7 +2903,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) { r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "8.9.10.11:1234" r.Header.Set("X-Forwarded-For", "1.1.1.1") - v, err := s.maybeAuthenticate(r) + _, v, err := s.maybeAuthenticate(r) require.Nil(t, err) require.Equal(t, "1.1.1.1", v.ip.String()) }) @@ -2917,7 +2917,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) { r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "8.9.10.11:1234" r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ") - v, err := s.maybeAuthenticate(r) + _, v, err := s.maybeAuthenticate(r) require.Nil(t, err) require.Equal(t, "234.5.2.1", v.ip.String()) }) @@ -2932,7 +2932,7 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) { r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "8.9.10.11:1234" r.Header.Set("X-Client-IP", "1.2.3.4") - v, err := s.maybeAuthenticate(r) + _, v, err := s.maybeAuthenticate(r) require.Nil(t, err) require.Equal(t, "1.2.3.4", v.ip.String()) }) @@ -2947,7 +2947,7 @@ func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) { r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "[2001:db8:9999::1]:1234" r.Header.Set("X-Client-IP", "2001:db8:7777::1") - v, err := s.maybeAuthenticate(r) + _, v, err := s.maybeAuthenticate(r) require.Nil(t, err) require.Equal(t, "2001:db8:7777::1", v.ip.String()) }) @@ -2963,7 +2963,7 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) { r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "8.9.10.11:1234" r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4") - v, err := s.maybeAuthenticate(r) + _, v, err := s.maybeAuthenticate(r) require.Nil(t, err) require.Equal(t, "5.6.7.8", v.ip.String()) }) @@ -2979,7 +2979,7 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) { r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "[2001:db8:2222::1]:1234" r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]") - v, err := s.maybeAuthenticate(r) + _, v, err := s.maybeAuthenticate(r) require.Nil(t, err) require.Equal(t, "2001:db8:3333::1", v.ip.String()) }) @@ -5218,3 +5218,46 @@ func TestServer_Publish_InvalidUTF8WithFirebase(t *testing.T) { require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"]) require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"]) } + +func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) { + // Real requests: exhaust the visitor request limit so ntfy returns 429s, and confirm the + // client IP is written to the ban file after it breaches the per-status ban limit. + banFile := filepath.Join(t.TempDir(), "ntfy-ban.log") + c := newTestConfig(t, "") + c.BanFile = banFile + c.BanWindow = time.Minute + c.BanThreshold = 2 // Ban after the weighted budget of 2 is exhausted + c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike + c.VisitorRequestLimitBurst = 2 // 429 quickly + s := newTestServer(t, c) + got429 := 0 + for i := 0; i < 10; i++ { + rr := request(t, s, "PUT", "/mytopic", "x", nil) + if rr.Code == 429 { + got429++ + } + } + require.Greater(t, got429, 2) + s.ban.Close() // Writes are async (runWriteLoop); Close flushes the buffer before we read + data, err := os.ReadFile(banFile) + require.NoError(t, err) + require.Contains(t, string(data), "9.9.9.9 9.9.9.9/32 429 42901") // +} + +func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) { + // Real requests that all succeed (200) must never trigger a ban, even with a low "*" fallback. + banFile := filepath.Join(t.TempDir(), "ntfy-ban.log") + c := newTestConfig(t, "") + c.BanFile = banFile + c.BanWindow = time.Minute + c.BanThreshold = 3 // Low threshold that would catch 200s if 2xx were not skipped + c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike + c.VisitorRequestLimitBurst = 100 // Stay under the request limit so every request is 200 + s := newTestServer(t, c) + for i := 0; i < 10; i++ { + rr := request(t, s, "PUT", "/mytopic", fmt.Sprintf("m%d", i), nil) + require.Equal(t, 200, rr.Code) + } + s.ban.Close() // Flush any buffered bans (there should be none) before asserting no file + require.NoFileExists(t, banFile) +}