Merge branch 'release-2.26.x'

This commit is contained in:
binwiederhier
2026-07-20 23:42:03 +02:00
17 changed files with 961 additions and 63 deletions

View File

@@ -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

185
ban/service.go Normal file
View File

@@ -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 "<RFC3339-UTC> <ip> <prefix> <http> <ntfy>" 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
})
}

253
ban/service_test.go Normal file
View File

@@ -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")) // <ip> <prefix> <http> <ntfy-code>
}
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) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
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
}

83
ban/weights.go Normal file
View File

@@ -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 != ""
}

73
ban/weights_test.go Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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 `<RFC3339-timestamp> <ip> <prefix> <http-code> <ntfy-code>`, 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
```
`<prefix>` is `<ip>` 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 `<ip>` (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+ <HOST> \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 |

View File

@@ -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

View File

@@ -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`

View File

@@ -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

55
server/ban_test.go Normal file
View File

@@ -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 ") // <ip> <prefix> <http-code> <ntfy-code>
}
// 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)
}

View File

@@ -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: "",

View File

@@ -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:<ip> or user:<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 {

View File

@@ -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
# "<RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>", 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
# <prefix> is <ip> 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)

View File

@@ -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 ...).

View File

@@ -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 {

View File

@@ -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") // <ip> <prefix> <http> <ntfy-code>
}
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)
}