From 469d263a5cdbdfb9fc321c8e21e0a34945c8f53c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 18 Jul 2026 08:41:25 +0200 Subject: [PATCH 1/4] Hotfix: Add ban-file/ban-threshold/ban-weights as a more lightweight mechanism abuse counter --- cmd/serve.go | 31 +++++ docs/releases.md | 6 + server/config.go | 108 ++++++++++++++++- server/server.go | 11 +- server/server.yml | 33 +++++ server/server_auth.go | 18 +-- server/server_middleware.go | 1 + server/server_test.go | 55 +++++++-- server/visitor.go | 77 ++++++++++++ server/visitor_test.go | 232 ++++++++++++++++++++++++++++++++++++ 10 files changed, 554 insertions(+), 18 deletions(-) create mode 100644 server/visitor_test.go diff --git a/cmd/serve.go b/cmd/serve.go index 9712f94f..2e0aaab0 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" @@ -98,6 +99,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 a visitor 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 +220,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 +279,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 := server.ParseBanWeights(banWeightsRaw) + if err != nil { + return err + } // Convert sizes to bytes messageSizeLimit, err := util.ParseSize(messageSizeLimitStr) @@ -372,6 +391,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 +539,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/releases.md b/docs/releases.md index bf52ef7a..ee4b1534 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2007,6 +2007,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.26.1 (UNRELEASED) + +**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 (see `ban-file`, `ban-window`, `ban-threshold`, `ban-weights`) + ### ntfy Android v1.25.1 (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out diff --git a/server/config.go b/server/config.go index de94b4ca..15938a47 100644 --- a/server/config.go +++ b/server/config.go @@ -7,6 +7,8 @@ import ( "io/fs" "net/netip" "reflect" + "strconv" + "strings" "text/template" "time" @@ -42,6 +44,18 @@ const ( DefaultWebPushExpiryDuration = 60 * 24 * time.Hour ) +// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights) +const ( + DefaultBanWindow = time.Minute + DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a visitor is banned +) + +// DefaultBanWeights defines the per-code strike weights used when the ban-feed is enabled but no +// explicit ban-weights are configured. Format is "KEY:WEIGHT" (see ParseBanWeights). The auth-failure +// flood is weighted heavily so it bans fast, the legit quota 429s are exempt (weight 0), and every +// other rejection costs one strike via the "*" fallback. +var DefaultBanWeights = []string{"42909:10", "42908:0", "42903:0", "42905:0", "42910:0", "*:1"} + // 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 @@ -197,9 +211,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 visitor is banned + BanWeights map[string]int // Abuse ban-feed: normalized code matcher -> strike weight (see ParseBanWeights, weightFor) + BuildVersion string // Injected by App + BuildDate string // Injected by App + BuildCommit string // Injected by App } // NewConfig instantiates a default new server config @@ -300,6 +318,10 @@ func NewConfig() *Config { WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, + BanFile: "", + BanWindow: DefaultBanWindow, + BanThreshold: DefaultBanThreshold, + BanWeights: nil, BuildVersion: "", BuildDate: "", BuildCommit: "", @@ -323,3 +345,83 @@ func (c *Config) Hash() string { } return fmt.Sprintf("%x", sha256.Sum256([]byte(result))) } + +// ParseBanWeights turns a list like ["42909:10","403:2","42908:0","*:1"] into a map of normalized +// matcher key -> strike weight for the abuse ban-feed's single weighted bucket. A key may be an exact +// ntfy code ("42909"), a prefix family ("429*"), or "*". A bare 3-digit HTTP status ("403") is a +// shorthand normalized to its family ("403*"), so operators can weight a whole status at once. Weights +// must be integers >= 0; a weight of 0 is valid and means the code is exempt (never contributes to a +// ban), which lets a "*" catch-all coexist with carved-out legit-quota codes. A malformed entry is +// rejected so misconfiguration surfaces at startup rather than silently disabling bans. +func ParseBanWeights(entries []string) (map[string]int, error) { + out := make(map[string]int, len(entries)) + for _, entry := range entries { + key, weightStr, ok := strings.Cut(entry, ":") + if !ok { + return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry) + } + weight, err := strconv.Atoi(strings.TrimSpace(weightStr)) + if err != nil || weight < 0 { + return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry) + } + key = strings.TrimSpace(key) + if !validBanWeightKey(key) { + return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*") + } + // A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*"). + if len(key) == 3 && isAllDigits(key) { + key += "*" + } + out[key] = weight + } + return out, nil +} + +// validBanWeightKey reports whether key is a legal ban-weight matcher: "*", an all-digits code, +// or an all-digits prefix followed by "*". +func validBanWeightKey(key string) bool { + if key == "*" { + return true + } + digits := strings.TrimSuffix(key, "*") + return digits != "" && isAllDigits(digits) +} + +func isAllDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return s != "" +} + +// weightFor returns the strike weight for a rejection's 5-digit ntfy code using the ban-weights +// matcher, longest-match-wins. An exact key ("42909") matches with length len(code); a family key +// ("429*") matches a code with that prefix, with length equal to the prefix; "*" matches everything +// with length 0. The weight of the longest match is returned, or 0 if no key matches (which the +// caller treats as "no strike"). +func (c *Config) weightFor(ntfyCode int) int { + code := strconv.Itoa(ntfyCode) + weight, bestLen, matched := 0, -1, false + for key, w := range c.BanWeights { + matchLen := -1 + switch { + case key == "*": + matchLen = 0 + case strings.HasSuffix(key, "*"): + if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) { + matchLen = len(prefix) + } + case key == code: + matchLen = len(code) + } + if matchLen > bestLen { + weight, bestLen, matched = w, matchLen, true + } + } + if !matched { + return 0 + } + return weight +} diff --git a/server/server.go b/server/server.go index 284d6d48..094d0d38 100644 --- a/server/server.go +++ b/server/server.go @@ -472,7 +472,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 @@ -538,6 +538,15 @@ 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.config.BanFile != "" { + // Abuse ban-feed: record against the OFFENDING request's IP, not v.ip. An account-keyed + // (tier'd) visitor is one object shared across all its source IPs, so v.ip is stale. The IP was + // already extracted in maybeAuthenticate and stashed in the request context (contextVisitorIP), + // so reuse it here rather than re-parsing headers. + if ip, err := fromContext[netip.Addr](r, contextVisitorIP); err == nil { + v.recordStatus(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 ee23dd8b..38132b8f 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..4405daea 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 recordStatus) ) func (s *Server) limitRequests(next handleFunc) handleFunc { diff --git a/server/server_test.go b/server/server_test.go index 6697ad4d..3961ad3a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2851,7 +2851,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()) }) @@ -2865,7 +2865,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()) }) @@ -2879,7 +2879,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()) }) @@ -2894,7 +2894,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()) }) @@ -2909,7 +2909,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()) }) @@ -2925,7 +2925,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()) }) @@ -2941,7 +2941,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()) }) @@ -5147,3 +5147,44 @@ 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) + 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) + } + require.NoFileExists(t, banFile) +} diff --git a/server/visitor.go b/server/visitor.go index 2f07d273..38eaec9f 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "net/netip" + "os" "sync" "time" @@ -70,6 +71,8 @@ type visitor struct { authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil firebase time.Time // Next allowed Firebase message seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) + banLimiter *rate.Limiter // Abuse ban-feed: single weighted breach detector (rate.Limiter is concurrency-safe), nil when feature disabled + banEmit *rate.Limiter // Abuse ban-feed: throttles writes so a persistent offender only re-appears occasionally, nil when disabled mu sync.RWMutex } @@ -142,6 +145,13 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man accountLimiter: nil, // Set in resetLimiters, may be nil authLimiter: nil, // Set in resetLimiters, may be nil } + // Abuse ban-feed: only wire up per-visitor tracking when the feature is enabled. One weighted + // token bucket (capacity BanThreshold, refilled at BanThreshold/BanWindow per second) is the + // breach detector; banEmit throttles writes so the feed stays tiny. + if conf.BanFile != "" { + v.banLimiter = rate.NewLimiter(rate.Limit(float64(conf.BanThreshold)/conf.BanWindow.Seconds()), conf.BanThreshold) + v.banEmit = rate.NewLimiter(rate.Every(conf.BanWindow), 1) + } v.resetLimitersNoLock(messages, emails, calls, false) return v } @@ -551,6 +561,73 @@ func dailyLimitToRate(limit int64) rate.Limit { return rate.Limit(limit) * rate.Every(oneDay) } +// recordStatus is called once per request with the offending request's IP and the final HTTP status +// and ntfy code. Each rejection (4xx/5xx only) consumes weightFor(ntfyCode) tokens from the visitor's +// single weighted ban bucket; a code weighted 0 (or one that matches no rule) is exempt and never +// counts. When the bucket cannot cover a rejection the visitor has breached, and ip is appended to +// the ban file that fail2ban tails (throttled via banEmit so a persistent offender only re-appears +// occasionally). ip is passed in rather than read from v.ip because an account-keyed (tier'd) visitor +// is shared across all its source IPs, so v.ip would be stale; we ban the address that breached. +// This is on the hot path, so it no-ops immediately when the feature is disabled. +func (v *visitor) recordStatus(ip netip.Addr, httpCode, ntfyCode int) { + if v.config.BanFile == "" { + return // Feature disabled + } + if httpCode < 400 { + return // Only rejections (4xx/5xx) count toward a ban; success and redirects never do + } + // Note: tier'd (account-keyed) visitors are NOT exempt. A persistent 429 stream is abusive + // regardless of the account behind it, and banning the offending IP is the right response -- + // exactly what the pre-existing nginx-side jails already did. The legit case (a paid user merely + // hitting a plan/quota limit) is spared by the per-code weights instead: quota codes like 42908 + // are weighted 0 below. Only request-limiter floods (42901, weight 1) actually accrue strikes. + weight := v.config.weightFor(ntfyCode) + if weight <= 0 { + return // Exempt (weight 0) or unmatched code: no strike + } + if v.banLimiter.AllowN(time.Now(), weight) { + return // The bucket covered this rejection; still within the strike budget + } + // Breached. Throttle so the feed stays tiny even if the offender keeps hammering. + if v.banEmit.Allow() { + writeBanLine(v.config.BanFile, ip, visitorPrefix(ip, v.config), httpCode, ntfyCode) + } +} + +// banFileMu serializes appends to the ban file across all visitors (multiple visitors may breach +// concurrently). The feed is pre-filtered, so write volume is low and a single mutex is plenty. +var banFileMu sync.Mutex + +// writeBanLine appends a single line to the ban file: +// +// +// +// e.g. "2026-07-18T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901". is masked to the configured +// rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6); that is the unit fail2ban should ban, so an IPv6 +// client (which owns a whole /64) is banned as one, matching how ntfy rate-limits it. This exact +// format is a contract that a fail2ban jail parses. Best-effort; any error is swallowed, because a +// failure to write the ban feed must never fail the underlying request. +func writeBanLine(path string, ip netip.Addr, prefix netip.Prefix, httpCode, ntfyCode int) { + banFileMu.Lock() + defer banFileMu.Unlock() + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "%s %s %s %d %d\n", time.Now().UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, ntfyCode) +} + +// visitorPrefix masks ip to the configured rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6), so the +// ban feed reports the same unit ntfy rate-limits by -- e.g. a whole /64 for an IPv6 client -- rather +// than a single address. fail2ban bans that prefix. +func visitorPrefix(ip netip.Addr, conf *Config) netip.Prefix { + if ip.Is4() { + return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked() + } + return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked() +} + // visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6 func visitorID(ip netip.Addr, u *user.User, conf *Config) string { if u != nil && u.Tier != nil { diff --git a/server/visitor_test.go b/server/visitor_test.go new file mode 100644 index 00000000..eb9d1abb --- /dev/null +++ b/server/visitor_test.go @@ -0,0 +1,232 @@ +package server + +import ( + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" +) + +// newBanTestVisitor creates a visitor wired for ban-feed 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 newBanTestVisitor(t *testing.T, banFile string, threshold int, weights map[string]int) *visitor { + conf := NewConfig() + conf.BanFile = banFile + conf.BanWindow = time.Minute + conf.BanThreshold = threshold + conf.BanWeights = weights + return newVisitor(conf, nil, nil, netip.MustParseAddr("1.2.3.4"), nil) +} + +func readBanLines(t *testing.T, path string) []string { + data, err := os.ReadFile(path) + require.NoError(t, err) + return strings.Split(strings.TrimRight(string(data), "\n"), "\n") +} + +func TestVisitor_RecordStatus_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. + v := newBanTestVisitor(t, banFile, 10, map[string]int{"*": 2}) + for i := 0; i < 5; i++ { + v.recordStatus(v.ip, 400, 40001) + } + require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over + v.recordStatus(v.ip, 400, 40001) // 6th hit cannot be covered -> breach + lines := readBanLines(t, 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 TestVisitor_RecordStatus_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. + v := newBanTestVisitor(t, banFile, 10, map[string]int{"42909": 10, "*": 1}) + v.recordStatus(v.ip, 429, 42909) + require.NoFileExists(t, banFile) + v.recordStatus(v.ip, 429, 42909) // 2nd hit cannot be covered -> breach + lines := readBanLines(t, 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 TestVisitor_RecordStatus_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. + v := newBanTestVisitor(t, banFile, 10, map[string]int{"42908": 0, "*": 1}) + for i := 0; i < 100; i++ { + v.recordStatus(v.ip, 429, 42908) + } + require.NoFileExists(t, banFile) +} + +func TestVisitor_RecordStatus_SingleBucketNoRelaxation(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + // One shared bucket: 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). + v := newBanTestVisitor(t, banFile, 10, map[string]int{"403*": 2, "*": 1}) + v.recordStatus(v.ip, 403, 40301) // weight 2 -> 8 left + v.recordStatus(v.ip, 403, 40301) // weight 2 -> 6 left + v.recordStatus(v.ip, 403, 40301) // weight 2 -> 4 left + require.NoFileExists(t, banFile) + for i := 0; i < 4; i++ { + v.recordStatus(v.ip, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left + } + require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over + v.recordStatus(v.ip, 400, 40001) // one more cannot be covered -> breach + lines := readBanLines(t, banFile) + require.Len(t, lines, 1) +} + +func TestVisitor_RecordStatus_ExactLineFormat(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) + before := time.Now().UTC().Truncate(time.Second) + for i := 0; i < 3; i++ { + v.recordStatus(v.ip, 429, 42901) + } + after := time.Now().UTC() + lines := readBanLines(t, 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 TestVisitor_RecordStatus_IPv6MaskedToPrefix(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + conf := NewConfig() + conf.BanFile = banFile + conf.BanWindow = time.Minute + conf.BanThreshold = 1 + conf.BanWeights = map[string]int{"*": 1} + v := newVisitor(conf, nil, nil, netip.MustParseAddr("2001:db8::abcd"), nil) + for i := 0; i < 3; i++ { + v.recordStatus(v.ip, 429, 42901) + } + parts := strings.Split(readBanLines(t, 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 TestVisitor_RecordStatus_TierUserAlsoBanned(t *testing.T) { + // A tier'd user who keeps hammering the request limiter (42901) is banned by IP like anyone + // else -- a persistent 429 stream is abusive regardless of the account behind it. The legit + // case (hitting a paid plan/quota limit) is spared by the per-code weights, not by a tier skip: + // codes like 42908 are weight 0. So tier does not exempt a visitor from the ban feed. + banFile := filepath.Join(t.TempDir(), "ban.log") + v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) + v.user = &user.User{ID: "u_test", Name: "test", Tier: &user.Tier{ID: "ti_test"}} + for i := 0; i < 10; i++ { + v.recordStatus(v.ip, 429, 42901) + } + lines := readBanLines(t, 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 TestVisitor_RecordStatus_BansOffendingIPNotVisitorIP(t *testing.T) { + // For an account-keyed (tier'd) visitor, one visitor object serves many source IPs and its + // stored v.ip is whichever IP created it first -- stale. The ban feed must write the IP of the + // request that actually breached, passed in per-call, not the visitor's stored IP. + banFile := filepath.Join(t.TempDir(), "ban.log") + v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) // v.ip == 1.2.3.4 + offender := netip.MustParseAddr("5.6.7.8") + for i := 0; i < 3; i++ { + v.recordStatus(offender, 429, 42901) + } + parts := strings.Split(readBanLines(t, banFile)[0], " ") + require.Equal(t, "5.6.7.8", parts[1]) // the offending request IP, not v.ip (1.2.3.4) + require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix, not the visitor's stored-IP prefix +} + +func TestVisitor_RecordStatus_DisabledWhenNoBanFile(t *testing.T) { + v := newBanTestVisitor(t, "", 10, map[string]int{"*": 1}) + for i := 0; i < 5; i++ { + v.recordStatus(v.ip, 403, 40301) // Feature disabled: must be a no-op, must not panic + } + require.Nil(t, v.banEmit) // No throttle limiter is created when the feature is off +} + +func TestVisitor_RecordStatus_Ignores2xx3xx(t *testing.T) { + banFile := filepath.Join(t.TempDir(), "ban.log") + v := newBanTestVisitor(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++ { + v.recordStatus(v.ip, 200, 20000) + v.recordStatus(v.ip, 302, 30000) + } + require.NoFileExists(t, banFile) + // A 4xx over the same budget still gets written. + for i := 0; i < 5; i++ { + v.recordStatus(v.ip, 400, 40001) + } + lines := readBanLines(t, 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 TestParseBanWeights(t *testing.T) { + // Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*". + weights, err := ParseBanWeights([]string{"42909:10", "403:2", "42908:0", "*:1"}) + require.NoError(t, err) + require.Equal(t, map[string]int{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights) + + // A bare 3-digit HTTP status normalizes to its family. + weights, err = ParseBanWeights([]string{"429:5"}) + require.NoError(t, err) + require.Equal(t, map[string]int{"429*": 5}, weights) + + // An explicit family key stays as-is. + weights, err = ParseBanWeights([]string{"429*:5"}) + require.NoError(t, err) + require.Equal(t, map[string]int{"429*": 5}, weights) + + // Weight 0 is valid and means exempt. + weights, err = ParseBanWeights([]string{"42908:0"}) + require.NoError(t, err) + require.Equal(t, map[string]int{"42908": 0}, weights) + + _, err = ParseBanWeights([]string{"401"}) // Missing weight + require.Error(t, err) + _, err = ParseBanWeights([]string{"401:-1"}) // Negative weight + require.Error(t, err) + _, err = ParseBanWeights([]string{"401:abc"}) // Non-integer weight + require.Error(t, err) + _, err = ParseBanWeights([]string{"abc:10"}) // Non-numeric key + require.Error(t, err) + _, err = ParseBanWeights([]string{"4*3:10"}) // Star not at the end + require.Error(t, err) +} + +func TestConfig_WeightFor(t *testing.T) { + conf := NewConfig() + weights, err := ParseBanWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"}) + require.NoError(t, err) + conf.BanWeights = weights + // Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*". + require.Equal(t, 0, conf.weightFor(42908)) + require.Equal(t, 10, conf.weightFor(42909)) + require.Equal(t, 1, conf.weightFor(42901)) + require.Equal(t, 2, conf.weightFor(40311)) + require.Equal(t, 1, conf.weightFor(40011)) + require.Equal(t, 1, conf.weightFor(50312)) + // A code that matches nothing (no "*" here) returns 0. + require.Equal(t, 0, conf.weightFor(30012)) +} From c6749856991afff2667d6c3634749bcb83492326 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 20 Jul 2026 21:34:47 +0200 Subject: [PATCH 2/4] Redo the banning logic, ban.Service --- CODE_OF_CONDUCT.md | 2 +- ban/service.go | 185 ++++++++++++++++++++++++++ ban/service_test.go | 253 ++++++++++++++++++++++++++++++++++++ ban/weights.go | 83 ++++++++++++ ban/weights_test.go | 73 +++++++++++ cmd/serve.go | 5 +- docs/config.md | 72 ++++++++++ docs/contact.md | 7 +- docs/releases.md | 2 +- server/ban_test.go | 55 ++++++++ server/config.go | 121 ++++------------- server/server.go | 25 +++- server/server_middleware.go | 2 +- server/server_test.go | 2 + server/visitor.go | 77 ----------- server/visitor_test.go | 232 --------------------------------- 16 files changed, 779 insertions(+), 417 deletions(-) create mode 100644 ban/service.go create mode 100644 ban/service_test.go create mode 100644 ban/weights.go create mode 100644 ban/weights_test.go create mode 100644 server/ban_test.go delete mode 100644 server/visitor_test.go 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 2e0aaab0..f0198d5f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -18,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" @@ -101,7 +102,7 @@ var flagsServe = append( 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 a visitor is banned"}), + 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)"}), @@ -285,7 +286,7 @@ func execServe(c *cli.Context) error { } // Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback) - banWeights, err := server.ParseBanWeights(banWeightsRaw) + banWeights, err := ban.ParseWeights(banWeightsRaw) if err != nil { return err } diff --git a/docs/config.md b/docs/config.md index 0f72557b..4173a65e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2129,6 +2129,74 @@ 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, which is useful to + spare legit plan/quota `429`s 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 + - "42908:0" # daily message quota reached -> legit, never counts + # 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. @@ -2328,6 +2396,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 ... *:1`| 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/releases.md b/docs/releases.md index ee4b1534..c1cd113a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2011,7 +2011,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **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 (see `ban-file`, `ban-window`, `ban-threshold`, `ban-weights`) +* 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 Android v1.25.1 (UNRELEASED) 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 15938a47..460ef42b 100644 --- a/server/config.go +++ b/server/config.go @@ -7,11 +7,10 @@ import ( "io/fs" "net/netip" "reflect" - "strconv" - "strings" "text/template" "time" + "heckel.io/ntfy/v2/ban" "heckel.io/ntfy/v2/user" ) @@ -46,15 +45,25 @@ const ( // Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights) const ( - DefaultBanWindow = time.Minute - DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a visitor is banned + DefaultBanWindow = 10 * time.Minute + DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned ) -// DefaultBanWeights defines the per-code strike weights used when the ban-feed is enabled but no -// explicit ban-weights are configured. Format is "KEY:WEIGHT" (see ParseBanWeights). The auth-failure -// flood is weighted heavily so it bans fast, the legit quota 429s are exempt (weight 0), and every -// other rejection costs one strike via the "*" fallback. -var DefaultBanWeights = []string{"42909:10", "42908:0", "42903:0", "42905:0", "42910:0", "*:1"} +// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast, +// legit quota codes are exempt (weight 0), and everything else defaults to weight 1 (no "*"; see WeightFor). +var DefaultBanWeights = []string{ + banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast + banWeight(errHTTPTooManyRequestsLimitMessages, 0), // daily message quota -> legit, exempt + banWeight(errHTTPTooManyRequestsLimitSubscriptions, 0), // too many subscriptions -> legit, exempt + banWeight(errHTTPTooManyRequestsLimitAttachmentBandwidth, 0), // daily attachment bandwidth -> legit, exempt + banWeight(errHTTPTooManyRequestsLimitCalls, 0), // daily phone-call quota -> legit, exempt +} + +// banWeight formats a "CODE:WEIGHT" ban-feed default from an ntfy error, so the codes stay in sync with +// the errHTTP definitions instead of being duplicated as string literals. +func banWeight(err *errHTTP, weight int) string { + return fmt.Sprintf("%d:%d", err.Code, weight) +} // Defines all global and per-visitor limits // - message size limit: the max number of bytes for a message @@ -211,13 +220,13 @@ type Config struct { WebPushStartupQueries string WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration - BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature - BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted - BanThreshold int // Abuse ban-feed: weighted strikes per window before a visitor is banned - BanWeights map[string]int // Abuse ban-feed: normalized code matcher -> strike weight (see ParseBanWeights, weightFor) - BuildVersion string // Injected by App - BuildDate string // Injected by App - BuildCommit string // Injected by App + BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature + BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted + BanThreshold int // Abuse ban-feed: weighted strikes per window before a prefix is banned + BanWeights ban.Weights // Abuse ban-feed: code matcher -> strike weight (see ban.ParseWeights, ban.Weights.WeightFor) + BuildVersion string // Injected by App + BuildDate string // Injected by App + BuildCommit string // Injected by App } // NewConfig instantiates a default new server config @@ -345,83 +354,3 @@ func (c *Config) Hash() string { } return fmt.Sprintf("%x", sha256.Sum256([]byte(result))) } - -// ParseBanWeights turns a list like ["42909:10","403:2","42908:0","*:1"] into a map of normalized -// matcher key -> strike weight for the abuse ban-feed's single weighted bucket. A key may be an exact -// ntfy code ("42909"), a prefix family ("429*"), or "*". A bare 3-digit HTTP status ("403") is a -// shorthand normalized to its family ("403*"), so operators can weight a whole status at once. Weights -// must be integers >= 0; a weight of 0 is valid and means the code is exempt (never contributes to a -// ban), which lets a "*" catch-all coexist with carved-out legit-quota codes. A malformed entry is -// rejected so misconfiguration surfaces at startup rather than silently disabling bans. -func ParseBanWeights(entries []string) (map[string]int, error) { - out := make(map[string]int, len(entries)) - for _, entry := range entries { - key, weightStr, ok := strings.Cut(entry, ":") - if !ok { - return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry) - } - weight, err := strconv.Atoi(strings.TrimSpace(weightStr)) - if err != nil || weight < 0 { - return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry) - } - key = strings.TrimSpace(key) - if !validBanWeightKey(key) { - return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*") - } - // A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*"). - if len(key) == 3 && isAllDigits(key) { - key += "*" - } - out[key] = weight - } - return out, nil -} - -// validBanWeightKey reports whether key is a legal ban-weight matcher: "*", an all-digits code, -// or an all-digits prefix followed by "*". -func validBanWeightKey(key string) bool { - if key == "*" { - return true - } - digits := strings.TrimSuffix(key, "*") - return digits != "" && isAllDigits(digits) -} - -func isAllDigits(s string) bool { - for _, r := range s { - if r < '0' || r > '9' { - return false - } - } - return s != "" -} - -// weightFor returns the strike weight for a rejection's 5-digit ntfy code using the ban-weights -// matcher, longest-match-wins. An exact key ("42909") matches with length len(code); a family key -// ("429*") matches a code with that prefix, with length equal to the prefix; "*" matches everything -// with length 0. The weight of the longest match is returned, or 0 if no key matches (which the -// caller treats as "no strike"). -func (c *Config) weightFor(ntfyCode int) int { - code := strconv.Itoa(ntfyCode) - weight, bestLen, matched := 0, -1, false - for key, w := range c.BanWeights { - matchLen := -1 - switch { - case key == "*": - matchLen = 0 - case strings.HasSuffix(key, "*"): - if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) { - matchLen = len(prefix) - } - case key == code: - matchLen = len(code) - } - if matchLen > bestLen { - weight, bestLen, matched = w, matchLen, true - } - } - if !matched { - return 0 - } - return weight -} diff --git a/server/server.go b/server/server.go index 094d0d38..a364c111 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" @@ -57,6 +58,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 *twilioClient messages int64 // Total number of messages (persisted if messageCache enabled) @@ -286,6 +288,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, @@ -295,6 +308,7 @@ func New(conf *Config) (*Server, error) { firebaseClient: firebaseClient, twilio: newTwilioClient(conf, userManager), mailer: sender, + ban: banner, topics: topics, userManager: userManager, messages: messages, @@ -450,6 +464,9 @@ func (s *Server) Stop() { s.attachment.Close() } s.closeDatabases() + if s.ban != nil { + s.ban.Close() + } if s.closeChan != nil { close(s.closeChan) } @@ -538,13 +555,9 @@ 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.config.BanFile != "" { - // Abuse ban-feed: record against the OFFENDING request's IP, not v.ip. An account-keyed - // (tier'd) visitor is one object shared across all its source IPs, so v.ip is stale. The IP was - // already extracted in maybeAuthenticate and stashed in the request context (contextVisitorIP), - // so reuse it here rather than re-parsing headers. + if s.ban != nil { if ip, err := fromContext[netip.Addr](r, contextVisitorIP); err == nil { - v.recordStatus(ip, httpErr.HTTPCode, httpErr.Code) + s.ban.Record(ip, httpErr.HTTPCode, httpErr.Code) } } } diff --git a/server/server_middleware.go b/server/server_middleware.go index 4405daea..117be70d 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -13,7 +13,7 @@ const ( contextRateVisitor contextKey = iota + 2586 contextTopic contextMatrixPushKey - contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see recordStatus) + 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 3961ad3a..45cbee45 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -5167,6 +5167,7 @@ func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) { } } 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") // @@ -5186,5 +5187,6 @@ func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) { 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) } diff --git a/server/visitor.go b/server/visitor.go index 38eaec9f..2f07d273 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -4,7 +4,6 @@ import ( "fmt" "math" "net/netip" - "os" "sync" "time" @@ -71,8 +70,6 @@ type visitor struct { authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil firebase time.Time // Next allowed Firebase message seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) - banLimiter *rate.Limiter // Abuse ban-feed: single weighted breach detector (rate.Limiter is concurrency-safe), nil when feature disabled - banEmit *rate.Limiter // Abuse ban-feed: throttles writes so a persistent offender only re-appears occasionally, nil when disabled mu sync.RWMutex } @@ -145,13 +142,6 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man accountLimiter: nil, // Set in resetLimiters, may be nil authLimiter: nil, // Set in resetLimiters, may be nil } - // Abuse ban-feed: only wire up per-visitor tracking when the feature is enabled. One weighted - // token bucket (capacity BanThreshold, refilled at BanThreshold/BanWindow per second) is the - // breach detector; banEmit throttles writes so the feed stays tiny. - if conf.BanFile != "" { - v.banLimiter = rate.NewLimiter(rate.Limit(float64(conf.BanThreshold)/conf.BanWindow.Seconds()), conf.BanThreshold) - v.banEmit = rate.NewLimiter(rate.Every(conf.BanWindow), 1) - } v.resetLimitersNoLock(messages, emails, calls, false) return v } @@ -561,73 +551,6 @@ func dailyLimitToRate(limit int64) rate.Limit { return rate.Limit(limit) * rate.Every(oneDay) } -// recordStatus is called once per request with the offending request's IP and the final HTTP status -// and ntfy code. Each rejection (4xx/5xx only) consumes weightFor(ntfyCode) tokens from the visitor's -// single weighted ban bucket; a code weighted 0 (or one that matches no rule) is exempt and never -// counts. When the bucket cannot cover a rejection the visitor has breached, and ip is appended to -// the ban file that fail2ban tails (throttled via banEmit so a persistent offender only re-appears -// occasionally). ip is passed in rather than read from v.ip because an account-keyed (tier'd) visitor -// is shared across all its source IPs, so v.ip would be stale; we ban the address that breached. -// This is on the hot path, so it no-ops immediately when the feature is disabled. -func (v *visitor) recordStatus(ip netip.Addr, httpCode, ntfyCode int) { - if v.config.BanFile == "" { - return // Feature disabled - } - if httpCode < 400 { - return // Only rejections (4xx/5xx) count toward a ban; success and redirects never do - } - // Note: tier'd (account-keyed) visitors are NOT exempt. A persistent 429 stream is abusive - // regardless of the account behind it, and banning the offending IP is the right response -- - // exactly what the pre-existing nginx-side jails already did. The legit case (a paid user merely - // hitting a plan/quota limit) is spared by the per-code weights instead: quota codes like 42908 - // are weighted 0 below. Only request-limiter floods (42901, weight 1) actually accrue strikes. - weight := v.config.weightFor(ntfyCode) - if weight <= 0 { - return // Exempt (weight 0) or unmatched code: no strike - } - if v.banLimiter.AllowN(time.Now(), weight) { - return // The bucket covered this rejection; still within the strike budget - } - // Breached. Throttle so the feed stays tiny even if the offender keeps hammering. - if v.banEmit.Allow() { - writeBanLine(v.config.BanFile, ip, visitorPrefix(ip, v.config), httpCode, ntfyCode) - } -} - -// banFileMu serializes appends to the ban file across all visitors (multiple visitors may breach -// concurrently). The feed is pre-filtered, so write volume is low and a single mutex is plenty. -var banFileMu sync.Mutex - -// writeBanLine appends a single line to the ban file: -// -// -// -// e.g. "2026-07-18T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901". is masked to the configured -// rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6); that is the unit fail2ban should ban, so an IPv6 -// client (which owns a whole /64) is banned as one, matching how ntfy rate-limits it. This exact -// format is a contract that a fail2ban jail parses. Best-effort; any error is swallowed, because a -// failure to write the ban feed must never fail the underlying request. -func writeBanLine(path string, ip netip.Addr, prefix netip.Prefix, httpCode, ntfyCode int) { - banFileMu.Lock() - defer banFileMu.Unlock() - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - fmt.Fprintf(f, "%s %s %s %d %d\n", time.Now().UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, ntfyCode) -} - -// visitorPrefix masks ip to the configured rate-limiting prefix (VisitorPrefixBitsIPv4/IPv6), so the -// ban feed reports the same unit ntfy rate-limits by -- e.g. a whole /64 for an IPv6 client -- rather -// than a single address. fail2ban bans that prefix. -func visitorPrefix(ip netip.Addr, conf *Config) netip.Prefix { - if ip.Is4() { - return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked() - } - return netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked() -} - // visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6 func visitorID(ip netip.Addr, u *user.User, conf *Config) string { if u != nil && u.Tier != nil { diff --git a/server/visitor_test.go b/server/visitor_test.go deleted file mode 100644 index eb9d1abb..00000000 --- a/server/visitor_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package server - -import ( - "net/netip" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/user" -) - -// newBanTestVisitor creates a visitor wired for ban-feed 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 newBanTestVisitor(t *testing.T, banFile string, threshold int, weights map[string]int) *visitor { - conf := NewConfig() - conf.BanFile = banFile - conf.BanWindow = time.Minute - conf.BanThreshold = threshold - conf.BanWeights = weights - return newVisitor(conf, nil, nil, netip.MustParseAddr("1.2.3.4"), nil) -} - -func readBanLines(t *testing.T, path string) []string { - data, err := os.ReadFile(path) - require.NoError(t, err) - return strings.Split(strings.TrimRight(string(data), "\n"), "\n") -} - -func TestVisitor_RecordStatus_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. - v := newBanTestVisitor(t, banFile, 10, map[string]int{"*": 2}) - for i := 0; i < 5; i++ { - v.recordStatus(v.ip, 400, 40001) - } - require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over - v.recordStatus(v.ip, 400, 40001) // 6th hit cannot be covered -> breach - lines := readBanLines(t, 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 TestVisitor_RecordStatus_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. - v := newBanTestVisitor(t, banFile, 10, map[string]int{"42909": 10, "*": 1}) - v.recordStatus(v.ip, 429, 42909) - require.NoFileExists(t, banFile) - v.recordStatus(v.ip, 429, 42909) // 2nd hit cannot be covered -> breach - lines := readBanLines(t, 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 TestVisitor_RecordStatus_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. - v := newBanTestVisitor(t, banFile, 10, map[string]int{"42908": 0, "*": 1}) - for i := 0; i < 100; i++ { - v.recordStatus(v.ip, 429, 42908) - } - require.NoFileExists(t, banFile) -} - -func TestVisitor_RecordStatus_SingleBucketNoRelaxation(t *testing.T) { - banFile := filepath.Join(t.TempDir(), "ban.log") - // One shared bucket: 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). - v := newBanTestVisitor(t, banFile, 10, map[string]int{"403*": 2, "*": 1}) - v.recordStatus(v.ip, 403, 40301) // weight 2 -> 8 left - v.recordStatus(v.ip, 403, 40301) // weight 2 -> 6 left - v.recordStatus(v.ip, 403, 40301) // weight 2 -> 4 left - require.NoFileExists(t, banFile) - for i := 0; i < 4; i++ { - v.recordStatus(v.ip, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left - } - require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over - v.recordStatus(v.ip, 400, 40001) // one more cannot be covered -> breach - lines := readBanLines(t, banFile) - require.Len(t, lines, 1) -} - -func TestVisitor_RecordStatus_ExactLineFormat(t *testing.T) { - banFile := filepath.Join(t.TempDir(), "ban.log") - v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) - before := time.Now().UTC().Truncate(time.Second) - for i := 0; i < 3; i++ { - v.recordStatus(v.ip, 429, 42901) - } - after := time.Now().UTC() - lines := readBanLines(t, 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 TestVisitor_RecordStatus_IPv6MaskedToPrefix(t *testing.T) { - banFile := filepath.Join(t.TempDir(), "ban.log") - conf := NewConfig() - conf.BanFile = banFile - conf.BanWindow = time.Minute - conf.BanThreshold = 1 - conf.BanWeights = map[string]int{"*": 1} - v := newVisitor(conf, nil, nil, netip.MustParseAddr("2001:db8::abcd"), nil) - for i := 0; i < 3; i++ { - v.recordStatus(v.ip, 429, 42901) - } - parts := strings.Split(readBanLines(t, 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 TestVisitor_RecordStatus_TierUserAlsoBanned(t *testing.T) { - // A tier'd user who keeps hammering the request limiter (42901) is banned by IP like anyone - // else -- a persistent 429 stream is abusive regardless of the account behind it. The legit - // case (hitting a paid plan/quota limit) is spared by the per-code weights, not by a tier skip: - // codes like 42908 are weight 0. So tier does not exempt a visitor from the ban feed. - banFile := filepath.Join(t.TempDir(), "ban.log") - v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) - v.user = &user.User{ID: "u_test", Name: "test", Tier: &user.Tier{ID: "ti_test"}} - for i := 0; i < 10; i++ { - v.recordStatus(v.ip, 429, 42901) - } - lines := readBanLines(t, 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 TestVisitor_RecordStatus_BansOffendingIPNotVisitorIP(t *testing.T) { - // For an account-keyed (tier'd) visitor, one visitor object serves many source IPs and its - // stored v.ip is whichever IP created it first -- stale. The ban feed must write the IP of the - // request that actually breached, passed in per-call, not the visitor's stored IP. - banFile := filepath.Join(t.TempDir(), "ban.log") - v := newBanTestVisitor(t, banFile, 1, map[string]int{"*": 1}) // v.ip == 1.2.3.4 - offender := netip.MustParseAddr("5.6.7.8") - for i := 0; i < 3; i++ { - v.recordStatus(offender, 429, 42901) - } - parts := strings.Split(readBanLines(t, banFile)[0], " ") - require.Equal(t, "5.6.7.8", parts[1]) // the offending request IP, not v.ip (1.2.3.4) - require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix, not the visitor's stored-IP prefix -} - -func TestVisitor_RecordStatus_DisabledWhenNoBanFile(t *testing.T) { - v := newBanTestVisitor(t, "", 10, map[string]int{"*": 1}) - for i := 0; i < 5; i++ { - v.recordStatus(v.ip, 403, 40301) // Feature disabled: must be a no-op, must not panic - } - require.Nil(t, v.banEmit) // No throttle limiter is created when the feature is off -} - -func TestVisitor_RecordStatus_Ignores2xx3xx(t *testing.T) { - banFile := filepath.Join(t.TempDir(), "ban.log") - v := newBanTestVisitor(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++ { - v.recordStatus(v.ip, 200, 20000) - v.recordStatus(v.ip, 302, 30000) - } - require.NoFileExists(t, banFile) - // A 4xx over the same budget still gets written. - for i := 0; i < 5; i++ { - v.recordStatus(v.ip, 400, 40001) - } - lines := readBanLines(t, 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 TestParseBanWeights(t *testing.T) { - // Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*". - weights, err := ParseBanWeights([]string{"42909:10", "403:2", "42908:0", "*:1"}) - require.NoError(t, err) - require.Equal(t, map[string]int{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights) - - // A bare 3-digit HTTP status normalizes to its family. - weights, err = ParseBanWeights([]string{"429:5"}) - require.NoError(t, err) - require.Equal(t, map[string]int{"429*": 5}, weights) - - // An explicit family key stays as-is. - weights, err = ParseBanWeights([]string{"429*:5"}) - require.NoError(t, err) - require.Equal(t, map[string]int{"429*": 5}, weights) - - // Weight 0 is valid and means exempt. - weights, err = ParseBanWeights([]string{"42908:0"}) - require.NoError(t, err) - require.Equal(t, map[string]int{"42908": 0}, weights) - - _, err = ParseBanWeights([]string{"401"}) // Missing weight - require.Error(t, err) - _, err = ParseBanWeights([]string{"401:-1"}) // Negative weight - require.Error(t, err) - _, err = ParseBanWeights([]string{"401:abc"}) // Non-integer weight - require.Error(t, err) - _, err = ParseBanWeights([]string{"abc:10"}) // Non-numeric key - require.Error(t, err) - _, err = ParseBanWeights([]string{"4*3:10"}) // Star not at the end - require.Error(t, err) -} - -func TestConfig_WeightFor(t *testing.T) { - conf := NewConfig() - weights, err := ParseBanWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"}) - require.NoError(t, err) - conf.BanWeights = weights - // Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*". - require.Equal(t, 0, conf.weightFor(42908)) - require.Equal(t, 10, conf.weightFor(42909)) - require.Equal(t, 1, conf.weightFor(42901)) - require.Equal(t, 2, conf.weightFor(40311)) - require.Equal(t, 1, conf.weightFor(40011)) - require.Equal(t, 1, conf.weightFor(50312)) - // A code that matches nothing (no "*" here) returns 0. - require.Equal(t, 0, conf.weightFor(30012)) -} From f6b03b44dda3415cd3d6da15a76307f81b868cf2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 20 Jul 2026 23:10:22 +0200 Subject: [PATCH 3/4] Bump release notes --- docs/config.md | 1 - docs/install.md | 76 ++++++++++++++++++++++++------------------------ docs/releases.md | 28 +++++++++++------- 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/docs/config.md b/docs/config.md index 4173a65e..948ffcbc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2196,7 +2196,6 @@ applies the prefix): bantime = 1h ``` - ## IPv6 support ntfy fully supports IPv6, though there are a few things to keep in mind. 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 c1cd113a..09fcdd76 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 @@ -2007,12 +2019,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.26.1 (UNRELEASED) - -**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 Android v1.25.1 (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out From 311138ef7b3314a140d8385e6f4549e1454fa97c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 20 Jul 2026 23:23:42 +0200 Subject: [PATCH 4/4] Derp --- docs/config.md | 7 +++---- server/config.go | 8 ++------ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/config.md b/docs/config.md index 948ffcbc..889c4c4e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2145,8 +2145,8 @@ every line is already a confirmed offender, the fail2ban jail can ban on first s `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, which is useful to - spare legit plan/quota `429`s from a `*` catch-all. Heavier weights ban faster. If you do not + `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. @@ -2172,7 +2172,6 @@ applies the prefix): ban-threshold: 100 ban-weights: - "42909:10" # too many auth failures -> brute force, ban fast - - "42908:0" # daily message quota reached -> legit, never counts # everything else 4xx/5xx defaults to weight 1 ``` @@ -2398,7 +2397,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `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 ... *:1`| 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) | +| `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/server/config.go b/server/config.go index 460ef42b..e3397e2d 100644 --- a/server/config.go +++ b/server/config.go @@ -50,13 +50,9 @@ const ( ) // DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast, -// legit quota codes are exempt (weight 0), and everything else defaults to weight 1 (no "*"; see WeightFor). +// 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(errHTTPTooManyRequestsLimitMessages, 0), // daily message quota -> legit, exempt - banWeight(errHTTPTooManyRequestsLimitSubscriptions, 0), // too many subscriptions -> legit, exempt - banWeight(errHTTPTooManyRequestsLimitAttachmentBandwidth, 0), // daily attachment bandwidth -> legit, exempt - banWeight(errHTTPTooManyRequestsLimitCalls, 0), // daily phone-call quota -> legit, exempt + banWeight(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