refactor(clientlimit): move the failed-auth limiter out of githttp
The API adapter shares the limiter and ClientAddr with smart HTTP, and adapters must not import each other.
- commit
- 6e542e5e8c37b8edddf9151307a9db996d929874
- author
- brice · · 2026-10-10 09:41 UTC
- parent
- 638a507
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +37 -32
M internal/adapters/githttp/handler.go +5 -4
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | ||
| 21 | 21 | "github.com/bnema/zerowrap" | |
| 22 | 22 | ||
| 23 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 23 | 24 | "bnema.dev/ermite/internal/domain" | |
| 24 | 25 | "bnema.dev/ermite/internal/ports" | |
| 25 | 26 | ) | |
| @@ -97,7 +98,7 @@ type Config struct { | |||
| 97 | 98 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 98 | 99 | TrustedProxies []netip.Prefix | |
| 99 | 100 | // Limiter counts failed authentications; nil builds a default one. | |
| 100 | - | Limiter *Limiter | |
| 101 | + | Limiter *clientlimit.Limiter | |
| 101 | 102 | // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes. | |
| 102 | 103 | // The body of a fetch is bounded by 64 MiB at most. | |
| 103 | 104 | MaxBodyBytes int64 | |
| @@ -106,7 +107,7 @@ type Config struct { | |||
| 106 | 107 | // Handler serves the smart HTTP routes of Patterns. | |
| 107 | 108 | type Handler struct { | |
| 108 | 109 | cfg Config | |
| 109 | - | limiter *Limiter | |
| 110 | + | limiter *clientlimit.Limiter | |
| 110 | 111 | maxBody int64 | |
| 111 | 112 | } | |
| 112 | 113 | ||
| @@ -114,7 +115,7 @@ type Handler struct { | |||
| 114 | 115 | func New(cfg Config) *Handler { | |
| 115 | 116 | h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes} | |
| 116 | 117 | if h.limiter == nil { | |
| 117 | - | h.limiter = NewLimiter(nil) | |
| 118 | + | h.limiter = clientlimit.NewLimiter(nil) | |
| 118 | 119 | } | |
| 119 | 120 | if h.maxBody <= 0 { | |
| 120 | 121 | h.maxBody = DefaultMaxBodyBytes | |
| @@ -194,7 +195,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 194 | 195 | ||
| 195 | 196 | ctx := r.Context() | |
| 196 | 197 | log := zerowrap.FromCtx(ctx) | |
| 197 | - | addr := ClientAddr(r, h.cfg.TrustedProxies) | |
| 198 | + | addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies) | |
| 198 | 199 | if blocked, wait := h.limiter.Blocked(ctx, addr); blocked { | |
| 199 | 200 | log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked") | |
| 200 | 201 | w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds())))) | |
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | 20 | ||||
| 21 | "github.com/bnema/zerowrap" | 21 | "github.com/bnema/zerowrap" | ||
| 22 | 22 | ||||
| 23 | + | "bnema.dev/ermite/internal/clientlimit" | |||
| 23 | "bnema.dev/ermite/internal/domain" | 24 | "bnema.dev/ermite/internal/domain" | ||
| 24 | "bnema.dev/ermite/internal/ports" | 25 | "bnema.dev/ermite/internal/ports" | ||
| 25 | ) | 26 | ) | ||
| @@ -97,7 +98,7 @@ type Config struct { | |||||
| 97 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | 98 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | ||
| 98 | TrustedProxies []netip.Prefix | 99 | TrustedProxies []netip.Prefix | ||
| 99 | // Limiter counts failed authentications; nil builds a default one. | 100 | // Limiter counts failed authentications; nil builds a default one. | ||
| 100 | - | Limiter *Limiter | 101 | + | Limiter *clientlimit.Limiter |
| 101 | // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes. | 102 | // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes. | ||
| 102 | // The body of a fetch is bounded by 64 MiB at most. | 103 | // The body of a fetch is bounded by 64 MiB at most. | ||
| 103 | MaxBodyBytes int64 | 104 | MaxBodyBytes int64 | ||
| @@ -106,7 +107,7 @@ type Config struct { | |||||
| 106 | // Handler serves the smart HTTP routes of Patterns. | 107 | // Handler serves the smart HTTP routes of Patterns. | ||
| 107 | type Handler struct { | 108 | type Handler struct { | ||
| 108 | cfg Config | 109 | cfg Config | ||
| 109 | - | limiter *Limiter | 110 | + | limiter *clientlimit.Limiter |
| 110 | maxBody int64 | 111 | maxBody int64 | ||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| @@ -114,7 +115,7 @@ type Handler struct { | |||||
| 114 | func New(cfg Config) *Handler { | 115 | func New(cfg Config) *Handler { | ||
| 115 | h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes} | 116 | h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes} | ||
| 116 | if h.limiter == nil { | 117 | if h.limiter == nil { | ||
| 117 | - | h.limiter = NewLimiter(nil) | 118 | + | h.limiter = clientlimit.NewLimiter(nil) |
| 118 | } | 119 | } | ||
| 119 | if h.maxBody <= 0 { | 120 | if h.maxBody <= 0 { | ||
| 120 | h.maxBody = DefaultMaxBodyBytes | 121 | h.maxBody = DefaultMaxBodyBytes | ||
| @@ -194,7 +195,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 194 | 195 | ||||
| 195 | ctx := r.Context() | 196 | ctx := r.Context() | ||
| 196 | log := zerowrap.FromCtx(ctx) | 197 | log := zerowrap.FromCtx(ctx) | ||
| 197 | - | addr := ClientAddr(r, h.cfg.TrustedProxies) | 198 | + | addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies) |
| 198 | if blocked, wait := h.limiter.Blocked(ctx, addr); blocked { | 199 | if blocked, wait := h.limiter.Blocked(ctx, addr); blocked { | ||
| 199 | log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked") | 200 | log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked") | ||
| 200 | w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds())))) | 201 | w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds())))) | ||
M internal/adapters/githttp/handler_test.go +4 -3
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | "github.com/stretchr/testify/require" | |
| 21 | 21 | ||
| 22 | 22 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 23 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 23 | 24 | "bnema.dev/ermite/internal/domain" | |
| 24 | 25 | "bnema.dev/ermite/internal/ports" | |
| 25 | 26 | "bnema.dev/ermite/internal/ports/mocks" | |
| @@ -410,8 +411,8 @@ func TestHandlerBackendFailure(t *testing.T) { | |||
| 410 | 411 | } | |
| 411 | 412 | ||
| 412 | 413 | func TestHandlerLimiter(t *testing.T) { | |
| 413 | - | r := newRig(t, func(c *githttp.Config) { c.Limiter = githttp.NewLimiter(nil) }) | |
| 414 | - | for i := range githttp.MaxFailures { | |
| 414 | + | r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) }) | |
| 415 | + | for i := range clientlimit.MaxFailures { | |
| 415 | 416 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil) | |
| 416 | 417 | require.Equal(t, 401, code, i) | |
| 417 | 418 | } | |
| @@ -432,7 +433,7 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |||
| 432 | 433 | r := newRig(t, func(c *githttp.Config) { | |
| 433 | 434 | c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} | |
| 434 | 435 | }) | |
| 435 | - | for range githttp.MaxFailures { | |
| 436 | + | for range clientlimit.MaxFailures { | |
| 436 | 437 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"}) | |
| 437 | 438 | } | |
| 438 | 439 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"}) | |
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | "github.com/stretchr/testify/require" | 20 | "github.com/stretchr/testify/require" | ||
| 21 | 21 | ||||
| 22 | "bnema.dev/ermite/internal/adapters/githttp" | 22 | "bnema.dev/ermite/internal/adapters/githttp" | ||
| 23 | + | "bnema.dev/ermite/internal/clientlimit" | |||
| 23 | "bnema.dev/ermite/internal/domain" | 24 | "bnema.dev/ermite/internal/domain" | ||
| 24 | "bnema.dev/ermite/internal/ports" | 25 | "bnema.dev/ermite/internal/ports" | ||
| 25 | "bnema.dev/ermite/internal/ports/mocks" | 26 | "bnema.dev/ermite/internal/ports/mocks" | ||
| @@ -410,8 +411,8 @@ func TestHandlerBackendFailure(t *testing.T) { | |||||
| 410 | } | 411 | } | ||
| 411 | 412 | ||||
| 412 | func TestHandlerLimiter(t *testing.T) { | 413 | func TestHandlerLimiter(t *testing.T) { | ||
| 413 | - | r := newRig(t, func(c *githttp.Config) { c.Limiter = githttp.NewLimiter(nil) }) | 414 | + | r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) }) |
| 414 | - | for i := range githttp.MaxFailures { | 415 | + | for i := range clientlimit.MaxFailures { |
| 415 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil) | 416 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil) | ||
| 416 | require.Equal(t, 401, code, i) | 417 | require.Equal(t, 401, code, i) | ||
| 417 | } | 418 | } | ||
| @@ -432,7 +433,7 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |||||
| 432 | r := newRig(t, func(c *githttp.Config) { | 433 | r := newRig(t, func(c *githttp.Config) { | ||
| 433 | c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} | 434 | c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} | ||
| 434 | }) | 435 | }) | ||
| 435 | - | for range githttp.MaxFailures { | 436 | + | for range clientlimit.MaxFailures { |
| 436 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"}) | 437 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"}) | ||
| 437 | } | 438 | } | ||
| 438 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"}) | 439 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"}) | ||
R internal/adapters/githttp/export_test.go → internal/clientlimit/export_test.go +1 -1 renamed, 93% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package githttp | |
| 3 | + | package clientlimit | |
| 4 | 4 | ||
| 5 | 5 | // MaxTracked is the bound on the addresses a Limiter remembers. | |
| 6 | 6 | const MaxTracked = maxTracked | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package githttp | 3 | + | package clientlimit |
| 4 | 4 | ||||
| 5 | // MaxTracked is the bound on the addresses a Limiter remembers. | 5 | // MaxTracked is the bound on the addresses a Limiter remembers. | ||
| 6 | const MaxTracked = maxTracked | 6 | const MaxTracked = maxTracked | ||
R internal/adapters/githttp/limiter.go → internal/clientlimit/limiter.go +4 -1 renamed, 96% similar
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package githttp | |
| 3 | + | // Package clientlimit counts failed authentications per client address and | |
| 4 | + | // finds the address of a request behind trusted proxies. The HTTP adapters | |
| 5 | + | // that authenticate callers (smart HTTP, the API) share it. | |
| 6 | + | package clientlimit | |
| 4 | 7 | ||
| 5 | 8 | import ( | |
| 6 | 9 | "context" | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package githttp | 3 | + | // Package clientlimit counts failed authentications per client address and |
| 4 | + | // finds the address of a request behind trusted proxies. The HTTP adapters | |||
| 5 | + | // that authenticate callers (smart HTTP, the API) share it. | |||
| 6 | + | package clientlimit | |||
| 4 | 7 | ||||
| 5 | import ( | 8 | import ( | ||
| 6 | "context" | 9 | "context" | ||
R internal/adapters/githttp/limiter_test.go → internal/clientlimit/limiter_test.go +23 -23 renamed, 86% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package githttp_test | |
| 3 | + | package clientlimit_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | - | "bnema.dev/ermite/internal/adapters/githttp" | |
| 16 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | var bg = context.Background() | |
| @@ -24,9 +24,9 @@ func (c *fakeClock) now() time.Time { return c.t } | |||
| 24 | 24 | ||
| 25 | 25 | func TestLimiter(t *testing.T) { | |
| 26 | 26 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 27 | - | l := githttp.NewLimiter(clk.now) | |
| 27 | + | l := clientlimit.NewLimiter(clk.now) | |
| 28 | 28 | ||
| 29 | - | for range githttp.MaxFailures - 1 { | |
| 29 | + | for range clientlimit.MaxFailures - 1 { | |
| 30 | 30 | l.Fail(bg, "1.2.3.4") | |
| 31 | 31 | } | |
| 32 | 32 | blocked, _ := l.Blocked(bg, "1.2.3.4") | |
| @@ -35,12 +35,12 @@ func TestLimiter(t *testing.T) { | |||
| 35 | 35 | l.Fail(bg, "1.2.3.4") | |
| 36 | 36 | blocked, wait := l.Blocked(bg, "1.2.3.4") | |
| 37 | 37 | assert.True(t, blocked, "10 failures block") | |
| 38 | - | assert.Equal(t, githttp.Window, wait) | |
| 38 | + | assert.Equal(t, clientlimit.Window, wait) | |
| 39 | 39 | ||
| 40 | 40 | other, _ := l.Blocked(bg, "5.6.7.8") | |
| 41 | 41 | assert.False(t, other, "another address is not affected") | |
| 42 | 42 | ||
| 43 | - | clk.t = clk.t.Add(githttp.Window - time.Second) | |
| 43 | + | clk.t = clk.t.Add(clientlimit.Window - time.Second) | |
| 44 | 44 | blocked, wait = l.Blocked(bg, "1.2.3.4") | |
| 45 | 45 | assert.True(t, blocked) | |
| 46 | 46 | assert.Equal(t, time.Second, wait) | |
| @@ -53,11 +53,11 @@ func TestLimiter(t *testing.T) { | |||
| 53 | 53 | ||
| 54 | 54 | func TestLimiterSlidingWindow(t *testing.T) { | |
| 55 | 55 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 56 | - | l := githttp.NewLimiter(clk.now) | |
| 56 | + | l := clientlimit.NewLimiter(clk.now) | |
| 57 | 57 | for range 5 { | |
| 58 | 58 | l.Fail(bg, "a") | |
| 59 | 59 | } | |
| 60 | - | clk.t = clk.t.Add(githttp.Window - time.Minute) | |
| 60 | + | clk.t = clk.t.Add(clientlimit.Window - time.Minute) | |
| 61 | 61 | for range 5 { | |
| 62 | 62 | l.Fail(bg, "a") | |
| 63 | 63 | } | |
| @@ -72,7 +72,7 @@ func TestLimiterSlidingWindow(t *testing.T) { | |||
| 72 | 72 | ||
| 73 | 73 | func TestLimiterOneAddressStaysSmall(t *testing.T) { | |
| 74 | 74 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 75 | - | l := githttp.NewLimiter(clk.now) | |
| 75 | + | l := clientlimit.NewLimiter(clk.now) | |
| 76 | 76 | for range 5000 { | |
| 77 | 77 | l.Fail(bg, "x") | |
| 78 | 78 | } | |
| @@ -83,16 +83,16 @@ func TestLimiterOneAddressStaysSmall(t *testing.T) { | |||
| 83 | 83 | ||
| 84 | 84 | func TestLimiterBoundsMemory(t *testing.T) { | |
| 85 | 85 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 86 | - | l := githttp.NewLimiter(clk.now) | |
| 87 | - | for i := range githttp.MaxTracked + 5000 { | |
| 86 | + | l := clientlimit.NewLimiter(clk.now) | |
| 87 | + | for i := range clientlimit.MaxTracked + 5000 { | |
| 88 | 88 | clk.t = clk.t.Add(time.Microsecond) // distinct, increasing last failures | |
| 89 | 89 | l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255)) | |
| 90 | 90 | } | |
| 91 | - | assert.LessOrEqual(t, l.Tracked(), githttp.MaxTracked) | |
| 91 | + | assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked) | |
| 92 | 92 | ||
| 93 | 93 | // The oldest were evicted, the newest kept. | |
| 94 | - | last := githttp.MaxTracked + 5000 - 1 | |
| 95 | - | for range githttp.MaxFailures - 1 { | |
| 94 | + | last := clientlimit.MaxTracked + 5000 - 1 | |
| 95 | + | for range clientlimit.MaxFailures - 1 { | |
| 96 | 96 | l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255)) | |
| 97 | 97 | } | |
| 98 | 98 | blocked, _ := l.Blocked(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255)) | |
| @@ -104,13 +104,13 @@ func TestLimiterBoundsMemory(t *testing.T) { | |||
| 104 | 104 | ||
| 105 | 105 | func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) { | |
| 106 | 106 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 107 | - | l := githttp.NewLimiter(clk.now) | |
| 107 | + | l := clientlimit.NewLimiter(clk.now) | |
| 108 | 108 | for i := range 100 { | |
| 109 | 109 | l.Fail(bg, fmt.Sprintf("192.0.2.%d", i)) | |
| 110 | 110 | } | |
| 111 | 111 | require.Equal(t, 100, l.Tracked()) | |
| 112 | 112 | ||
| 113 | - | clk.t = clk.t.Add(githttp.Window + time.Second) | |
| 113 | + | clk.t = clk.t.Add(clientlimit.Window + time.Second) | |
| 114 | 114 | l.Fail(bg, "198.51.100.1") // first call past the interval: sweeps | |
| 115 | 115 | assert.Equal(t, 1, l.Tracked(), "expired entries are gone") | |
| 116 | 116 | } | |
| @@ -119,8 +119,8 @@ func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) { | |||
| 119 | 119 | // O(n) pass per Fail. | |
| 120 | 120 | func BenchmarkLimiterFailAtMaxTracked(b *testing.B) { | |
| 121 | 121 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 122 | - | l := githttp.NewLimiter(clk.now) | |
| 123 | - | keys := make([]string, githttp.MaxTracked) | |
| 122 | + | l := clientlimit.NewLimiter(clk.now) | |
| 123 | + | keys := make([]string, clientlimit.MaxTracked) | |
| 124 | 124 | for i := range keys { | |
| 125 | 125 | keys[i] = fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255) | |
| 126 | 126 | clk.t = clk.t.Add(time.Microsecond) | |
| @@ -136,10 +136,10 @@ func BenchmarkLimiterFailAtMaxTracked(b *testing.B) { | |||
| 136 | 136 | ||
| 137 | 137 | func TestLimiterGroupsIPv6By64(t *testing.T) { | |
| 138 | 138 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 139 | - | l := githttp.NewLimiter(clk.now) | |
| 139 | + | l := clientlimit.NewLimiter(clk.now) | |
| 140 | 140 | ||
| 141 | 141 | // Ten different addresses of one /64 count together. | |
| 142 | - | for i := range githttp.MaxFailures { | |
| 142 | + | for i := range clientlimit.MaxFailures { | |
| 143 | 143 | l.Fail(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1)) | |
| 144 | 144 | } | |
| 145 | 145 | assert.Equal(t, 1, l.Tracked()) | |
| @@ -156,8 +156,8 @@ func TestLimiterGroupsIPv6By64(t *testing.T) { | |||
| 156 | 156 | ||
| 157 | 157 | func TestLimiterIPv4StaysPerAddress(t *testing.T) { | |
| 158 | 158 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 159 | - | l := githttp.NewLimiter(clk.now) | |
| 160 | - | for range githttp.MaxFailures { | |
| 159 | + | l := clientlimit.NewLimiter(clk.now) | |
| 160 | + | for range clientlimit.MaxFailures { | |
| 161 | 161 | l.Fail(bg, "203.0.113.5") | |
| 162 | 162 | } | |
| 163 | 163 | blocked, _ := l.Blocked(bg, "203.0.113.5") | |
| @@ -198,7 +198,7 @@ func TestClientAddr(t *testing.T) { | |||
| 198 | 198 | for _, v := range tt.xff { | |
| 199 | 199 | r.Header.Add("X-Forwarded-For", v) | |
| 200 | 200 | } | |
| 201 | - | assert.Equal(t, tt.want, githttp.ClientAddr(r, tt.trusted)) | |
| 201 | + | assert.Equal(t, tt.want, clientlimit.ClientAddr(r, tt.trusted)) | |
| 202 | 202 | }) | |
| 203 | 203 | } | |
| 204 | 204 | } | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | 2 | ||||
| 3 | - | package githttp_test | 3 | + | package clientlimit_test |
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | - | "bnema.dev/ermite/internal/adapters/githttp" | 16 | + | "bnema.dev/ermite/internal/clientlimit" |
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | var bg = context.Background() | 19 | var bg = context.Background() | ||
| @@ -24,9 +24,9 @@ func (c *fakeClock) now() time.Time { return c.t } | |||||
| 24 | 24 | ||||
| 25 | func TestLimiter(t *testing.T) { | 25 | func TestLimiter(t *testing.T) { | ||
| 26 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 26 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 27 | - | l := githttp.NewLimiter(clk.now) | 27 | + | l := clientlimit.NewLimiter(clk.now) |
| 28 | 28 | ||||
| 29 | - | for range githttp.MaxFailures - 1 { | 29 | + | for range clientlimit.MaxFailures - 1 { |
| 30 | l.Fail(bg, "1.2.3.4") | 30 | l.Fail(bg, "1.2.3.4") | ||
| 31 | } | 31 | } | ||
| 32 | blocked, _ := l.Blocked(bg, "1.2.3.4") | 32 | blocked, _ := l.Blocked(bg, "1.2.3.4") | ||
| @@ -35,12 +35,12 @@ func TestLimiter(t *testing.T) { | |||||
| 35 | l.Fail(bg, "1.2.3.4") | 35 | l.Fail(bg, "1.2.3.4") | ||
| 36 | blocked, wait := l.Blocked(bg, "1.2.3.4") | 36 | blocked, wait := l.Blocked(bg, "1.2.3.4") | ||
| 37 | assert.True(t, blocked, "10 failures block") | 37 | assert.True(t, blocked, "10 failures block") | ||
| 38 | - | assert.Equal(t, githttp.Window, wait) | 38 | + | assert.Equal(t, clientlimit.Window, wait) |
| 39 | 39 | ||||
| 40 | other, _ := l.Blocked(bg, "5.6.7.8") | 40 | other, _ := l.Blocked(bg, "5.6.7.8") | ||
| 41 | assert.False(t, other, "another address is not affected") | 41 | assert.False(t, other, "another address is not affected") | ||
| 42 | 42 | ||||
| 43 | - | clk.t = clk.t.Add(githttp.Window - time.Second) | 43 | + | clk.t = clk.t.Add(clientlimit.Window - time.Second) |
| 44 | blocked, wait = l.Blocked(bg, "1.2.3.4") | 44 | blocked, wait = l.Blocked(bg, "1.2.3.4") | ||
| 45 | assert.True(t, blocked) | 45 | assert.True(t, blocked) | ||
| 46 | assert.Equal(t, time.Second, wait) | 46 | assert.Equal(t, time.Second, wait) | ||
| @@ -53,11 +53,11 @@ func TestLimiter(t *testing.T) { | |||||
| 53 | 53 | ||||
| 54 | func TestLimiterSlidingWindow(t *testing.T) { | 54 | func TestLimiterSlidingWindow(t *testing.T) { | ||
| 55 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 55 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 56 | - | l := githttp.NewLimiter(clk.now) | 56 | + | l := clientlimit.NewLimiter(clk.now) |
| 57 | for range 5 { | 57 | for range 5 { | ||
| 58 | l.Fail(bg, "a") | 58 | l.Fail(bg, "a") | ||
| 59 | } | 59 | } | ||
| 60 | - | clk.t = clk.t.Add(githttp.Window - time.Minute) | 60 | + | clk.t = clk.t.Add(clientlimit.Window - time.Minute) |
| 61 | for range 5 { | 61 | for range 5 { | ||
| 62 | l.Fail(bg, "a") | 62 | l.Fail(bg, "a") | ||
| 63 | } | 63 | } | ||
| @@ -72,7 +72,7 @@ func TestLimiterSlidingWindow(t *testing.T) { | |||||
| 72 | 72 | ||||
| 73 | func TestLimiterOneAddressStaysSmall(t *testing.T) { | 73 | func TestLimiterOneAddressStaysSmall(t *testing.T) { | ||
| 74 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 74 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 75 | - | l := githttp.NewLimiter(clk.now) | 75 | + | l := clientlimit.NewLimiter(clk.now) |
| 76 | for range 5000 { | 76 | for range 5000 { | ||
| 77 | l.Fail(bg, "x") | 77 | l.Fail(bg, "x") | ||
| 78 | } | 78 | } | ||
| @@ -83,16 +83,16 @@ func TestLimiterOneAddressStaysSmall(t *testing.T) { | |||||
| 83 | 83 | ||||
| 84 | func TestLimiterBoundsMemory(t *testing.T) { | 84 | func TestLimiterBoundsMemory(t *testing.T) { | ||
| 85 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 85 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 86 | - | l := githttp.NewLimiter(clk.now) | 86 | + | l := clientlimit.NewLimiter(clk.now) |
| 87 | - | for i := range githttp.MaxTracked + 5000 { | 87 | + | for i := range clientlimit.MaxTracked + 5000 { |
| 88 | clk.t = clk.t.Add(time.Microsecond) // distinct, increasing last failures | 88 | clk.t = clk.t.Add(time.Microsecond) // distinct, increasing last failures | ||
| 89 | l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255)) | 89 | l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255)) | ||
| 90 | } | 90 | } | ||
| 91 | - | assert.LessOrEqual(t, l.Tracked(), githttp.MaxTracked) | 91 | + | assert.LessOrEqual(t, l.Tracked(), clientlimit.MaxTracked) |
| 92 | 92 | ||||
| 93 | // The oldest were evicted, the newest kept. | 93 | // The oldest were evicted, the newest kept. | ||
| 94 | - | last := githttp.MaxTracked + 5000 - 1 | 94 | + | last := clientlimit.MaxTracked + 5000 - 1 |
| 95 | - | for range githttp.MaxFailures - 1 { | 95 | + | for range clientlimit.MaxFailures - 1 { |
| 96 | l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255)) | 96 | l.Fail(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255)) | ||
| 97 | } | 97 | } | ||
| 98 | blocked, _ := l.Blocked(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255)) | 98 | blocked, _ := l.Blocked(bg, fmt.Sprintf("10.%d.%d.%d", last>>16&255, last>>8&255, last&255)) | ||
| @@ -104,13 +104,13 @@ func TestLimiterBoundsMemory(t *testing.T) { | |||||
| 104 | 104 | ||||
| 105 | func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) { | 105 | func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) { | ||
| 106 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 106 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 107 | - | l := githttp.NewLimiter(clk.now) | 107 | + | l := clientlimit.NewLimiter(clk.now) |
| 108 | for i := range 100 { | 108 | for i := range 100 { | ||
| 109 | l.Fail(bg, fmt.Sprintf("192.0.2.%d", i)) | 109 | l.Fail(bg, fmt.Sprintf("192.0.2.%d", i)) | ||
| 110 | } | 110 | } | ||
| 111 | require.Equal(t, 100, l.Tracked()) | 111 | require.Equal(t, 100, l.Tracked()) | ||
| 112 | 112 | ||||
| 113 | - | clk.t = clk.t.Add(githttp.Window + time.Second) | 113 | + | clk.t = clk.t.Add(clientlimit.Window + time.Second) |
| 114 | l.Fail(bg, "198.51.100.1") // first call past the interval: sweeps | 114 | l.Fail(bg, "198.51.100.1") // first call past the interval: sweeps | ||
| 115 | assert.Equal(t, 1, l.Tracked(), "expired entries are gone") | 115 | assert.Equal(t, 1, l.Tracked(), "expired entries are gone") | ||
| 116 | } | 116 | } | ||
| @@ -119,8 +119,8 @@ func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) { | |||||
| 119 | // O(n) pass per Fail. | 119 | // O(n) pass per Fail. | ||
| 120 | func BenchmarkLimiterFailAtMaxTracked(b *testing.B) { | 120 | func BenchmarkLimiterFailAtMaxTracked(b *testing.B) { | ||
| 121 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 121 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 122 | - | l := githttp.NewLimiter(clk.now) | 122 | + | l := clientlimit.NewLimiter(clk.now) |
| 123 | - | keys := make([]string, githttp.MaxTracked) | 123 | + | keys := make([]string, clientlimit.MaxTracked) |
| 124 | for i := range keys { | 124 | for i := range keys { | ||
| 125 | keys[i] = fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255) | 125 | keys[i] = fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255) | ||
| 126 | clk.t = clk.t.Add(time.Microsecond) | 126 | clk.t = clk.t.Add(time.Microsecond) | ||
| @@ -136,10 +136,10 @@ func BenchmarkLimiterFailAtMaxTracked(b *testing.B) { | |||||
| 136 | 136 | ||||
| 137 | func TestLimiterGroupsIPv6By64(t *testing.T) { | 137 | func TestLimiterGroupsIPv6By64(t *testing.T) { | ||
| 138 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 138 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 139 | - | l := githttp.NewLimiter(clk.now) | 139 | + | l := clientlimit.NewLimiter(clk.now) |
| 140 | 140 | ||||
| 141 | // Ten different addresses of one /64 count together. | 141 | // Ten different addresses of one /64 count together. | ||
| 142 | - | for i := range githttp.MaxFailures { | 142 | + | for i := range clientlimit.MaxFailures { |
| 143 | l.Fail(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1)) | 143 | l.Fail(bg, fmt.Sprintf("2001:db8:1:2::%x", i+1)) | ||
| 144 | } | 144 | } | ||
| 145 | assert.Equal(t, 1, l.Tracked()) | 145 | assert.Equal(t, 1, l.Tracked()) | ||
| @@ -156,8 +156,8 @@ func TestLimiterGroupsIPv6By64(t *testing.T) { | |||||
| 156 | 156 | ||||
| 157 | func TestLimiterIPv4StaysPerAddress(t *testing.T) { | 157 | func TestLimiterIPv4StaysPerAddress(t *testing.T) { | ||
| 158 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | 158 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | ||
| 159 | - | l := githttp.NewLimiter(clk.now) | 159 | + | l := clientlimit.NewLimiter(clk.now) |
| 160 | - | for range githttp.MaxFailures { | 160 | + | for range clientlimit.MaxFailures { |
| 161 | l.Fail(bg, "203.0.113.5") | 161 | l.Fail(bg, "203.0.113.5") | ||
| 162 | } | 162 | } | ||
| 163 | blocked, _ := l.Blocked(bg, "203.0.113.5") | 163 | blocked, _ := l.Blocked(bg, "203.0.113.5") | ||
| @@ -198,7 +198,7 @@ func TestClientAddr(t *testing.T) { | |||||
| 198 | for _, v := range tt.xff { | 198 | for _, v := range tt.xff { | ||
| 199 | r.Header.Add("X-Forwarded-For", v) | 199 | r.Header.Add("X-Forwarded-For", v) | ||
| 200 | } | 200 | } | ||
| 201 | - | assert.Equal(t, tt.want, githttp.ClientAddr(r, tt.trusted)) | 201 | + | assert.Equal(t, tt.want, clientlimit.ClientAddr(r, tt.trusted)) |
| 202 | }) | 202 | }) | ||
| 203 | } | 203 | } | ||
| 204 | } | 204 | } | ||