Compare commits
1 Commits
feature/co
...
feature/lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bcc711a92 |
45
README.md
45
README.md
@@ -2203,6 +2203,8 @@ directory is also loaded automatically via
|
|||||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||||
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
||||||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||||||
|
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||||||
|
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||||
|
|
||||||
### Example `.env` file
|
### Example `.env` file
|
||||||
@@ -2607,6 +2609,49 @@ creating one session pays once and keeps their session.
|
|||||||
- **Language-agnostic**: SHA-256 is available in every programming language.
|
- **Language-agnostic**: SHA-256 is available in every programming language.
|
||||||
The proof computation is trivially implementable in any client.
|
The proof computation is trivially implementable in any client.
|
||||||
|
|
||||||
|
### Login Rate Limiting
|
||||||
|
|
||||||
|
The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent
|
||||||
|
brute-force password attacks. This uses a token-bucket algorithm
|
||||||
|
(`golang.org/x/time/rate`) with configurable rate and burst.
|
||||||
|
|
||||||
|
| Environment Variable | Default | Description |
|
||||||
|
|---------------------|---------|-------------|
|
||||||
|
| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP |
|
||||||
|
| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP |
|
||||||
|
|
||||||
|
When the limit is exceeded, the server returns **429 Too Many Requests** with a
|
||||||
|
`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up
|
||||||
|
every 10 minutes.
|
||||||
|
|
||||||
|
> **⚠️ Security: Reverse Proxy Required for Production Use**
|
||||||
|
>
|
||||||
|
> The rate limiter extracts the client IP by checking the `X-Forwarded-For`
|
||||||
|
> header first, then `X-Real-IP`, and finally falling back to the TCP
|
||||||
|
> `RemoteAddr`. Both `X-Forwarded-For` and `X-Real-IP` are **client-controlled
|
||||||
|
> request headers** — any client can set them to arbitrary values.
|
||||||
|
>
|
||||||
|
> Without a properly configured reverse proxy in front of this server:
|
||||||
|
>
|
||||||
|
> - An attacker can **bypass rate limiting entirely** by rotating
|
||||||
|
> `X-Forwarded-For` values on each request (each value is treated as a
|
||||||
|
> distinct IP).
|
||||||
|
> - An attacker can **deny service to a specific user** by spoofing that user's
|
||||||
|
> IP in the `X-Forwarded-For` header, exhausting their rate limit bucket.
|
||||||
|
>
|
||||||
|
> **Recommendation:** Always deploy behind a reverse proxy (e.g. nginx, Caddy,
|
||||||
|
> Traefik) that strips or overwrites incoming `X-Forwarded-For` and `X-Real-IP`
|
||||||
|
> headers with the actual client IP. If running without a reverse proxy, be
|
||||||
|
> aware that the rate limiting provides no meaningful protection against a
|
||||||
|
> targeted attack.
|
||||||
|
|
||||||
|
**Why rate limits here but not on session creation?** Session creation is
|
||||||
|
protected by hashcash proof-of-work (stateless, no IP tracking needed). Login
|
||||||
|
involves bcrypt password verification against a registered account — a
|
||||||
|
fundamentally different threat model where an attacker targets a specific
|
||||||
|
account. Per-IP rate limiting is appropriate here because the cost of a wrong
|
||||||
|
guess is borne by the server (bcrypt), not the client.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
|
golang.org/x/time v0.6.0
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -151,6 +151,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ type Config struct {
|
|||||||
HashcashBits int
|
HashcashBits int
|
||||||
OperName string
|
OperName string
|
||||||
OperPassword string
|
OperPassword string
|
||||||
|
LoginRateLimit float64
|
||||||
|
LoginRateBurst int
|
||||||
params *Params
|
params *Params
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,8 @@ func New(
|
|||||||
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
|
||||||
viper.SetDefault("NEOIRC_OPER_NAME", "")
|
viper.SetDefault("NEOIRC_OPER_NAME", "")
|
||||||
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
viper.SetDefault("NEOIRC_OPER_PASSWORD", "")
|
||||||
|
viper.SetDefault("LOGIN_RATE_LIMIT", "1")
|
||||||
|
viper.SetDefault("LOGIN_RATE_BURST", "5")
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,6 +114,8 @@ func New(
|
|||||||
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
|
||||||
OperName: viper.GetString("NEOIRC_OPER_NAME"),
|
OperName: viper.GetString("NEOIRC_OPER_NAME"),
|
||||||
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"),
|
||||||
|
LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"),
|
||||||
|
LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"),
|
||||||
log: log,
|
log: log,
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2380,6 +2380,121 @@ func TestSessionUsernameDefault(t *testing.T) {
|
|||||||
_ = token
|
_ = token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimitExceeded(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
// Exhaust the burst (default: 5 per IP) using
|
||||||
|
// nonexistent users. These fail fast (no bcrypt),
|
||||||
|
// preventing token replenishment between requests.
|
||||||
|
for range 5 {
|
||||||
|
loginBody, mErr := json.Marshal(
|
||||||
|
map[string]string{
|
||||||
|
"nick": "nosuchuser",
|
||||||
|
"password": "doesnotmatter",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if mErr != nil {
|
||||||
|
t.Fatal(mErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp, rErr := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/login"),
|
||||||
|
bytes.NewReader(loginBody),
|
||||||
|
)
|
||||||
|
if rErr != nil {
|
||||||
|
t.Fatal(rErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = loginResp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next request should be rate-limited.
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "nosuchuser", "password": "doesnotmatter",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/login"),
|
||||||
|
bytes.NewReader(loginBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 429, got %d",
|
||||||
|
resp.StatusCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
|
if retryAfter == "" {
|
||||||
|
t.Fatal("expected Retry-After header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
// Register a user.
|
||||||
|
regBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "normaluser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/register"),
|
||||||
|
bytes.NewReader(regBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// A single login should succeed without rate limiting.
|
||||||
|
loginBody, err := json.Marshal(map[string]string{
|
||||||
|
"nick": "normaluser", "password": "password123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp2, err := doRequest(
|
||||||
|
t,
|
||||||
|
http.MethodPost,
|
||||||
|
tserver.url("/api/v1/login"),
|
||||||
|
bytes.NewReader(loginBody),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp2.Body.Close() }()
|
||||||
|
|
||||||
|
if resp2.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp2.Body)
|
||||||
|
t.Fatalf(
|
||||||
|
"expected 200, got %d: %s",
|
||||||
|
resp2.StatusCode, respBody,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNickBroadcastToChannels(t *testing.T) {
|
func TestNickBroadcastToChannels(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
aliceToken := tserver.createSession("nick_a")
|
aliceToken := tserver.createSession("nick_a")
|
||||||
|
|||||||
@@ -168,6 +168,21 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
) {
|
) {
|
||||||
|
ip := clientIP(request)
|
||||||
|
|
||||||
|
if !hdlr.loginLimiter.Allow(ip) {
|
||||||
|
writer.Header().Set(
|
||||||
|
"Retry-After", "1",
|
||||||
|
)
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"too many login attempts, try again later",
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Nick string `json:"nick"`
|
Nick string `json:"nick"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -198,6 +213,16 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hdlr.executeLogin(
|
||||||
|
writer, request, payload.Nick, payload.Password,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (hdlr *Handlers) executeLogin(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
nick, password string,
|
||||||
|
) {
|
||||||
remoteIP := clientIP(request)
|
remoteIP := clientIP(request)
|
||||||
|
|
||||||
hostname := resolveHostname(
|
hostname := resolveHostname(
|
||||||
@@ -207,8 +232,7 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
sessionID, clientID, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
hdlr.params.Database.LoginUser(
|
hdlr.params.Database.LoginUser(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
payload.Nick,
|
nick, password,
|
||||||
payload.Password,
|
|
||||||
remoteIP, hostname,
|
remoteIP, hostname,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -224,18 +248,18 @@ func (hdlr *Handlers) handleLogin(
|
|||||||
hdlr.stats.IncrConnections()
|
hdlr.stats.IncrConnections()
|
||||||
|
|
||||||
hdlr.deliverMOTD(
|
hdlr.deliverMOTD(
|
||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize channel state so the new client knows
|
// Initialize channel state so the new client knows
|
||||||
// which channels the session already belongs to.
|
// which channels the session already belongs to.
|
||||||
hdlr.initChannelState(
|
hdlr.initChannelState(
|
||||||
request, clientID, sessionID, payload.Nick,
|
request, clientID, sessionID, nick,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request, map[string]any{
|
hdlr.respondJSON(writer, request, map[string]any{
|
||||||
"id": sessionID,
|
"id": sessionID,
|
||||||
"nick": payload.Nick,
|
"nick": nick,
|
||||||
"token": token,
|
"token": token,
|
||||||
}, http.StatusOK)
|
}, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
"git.eeqj.de/sneak/neoirc/internal/stats"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
@@ -49,6 +50,7 @@ type Handlers struct {
|
|||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
hashcashVal *hashcash.Validator
|
hashcashVal *hashcash.Validator
|
||||||
channelHashcash *hashcash.ChannelValidator
|
channelHashcash *hashcash.ChannelValidator
|
||||||
|
loginLimiter *ratelimit.Limiter
|
||||||
stats *stats.Tracker
|
stats *stats.Tracker
|
||||||
cancelCleanup context.CancelFunc
|
cancelCleanup context.CancelFunc
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,16 @@ func New(
|
|||||||
resource = "neoirc"
|
resource = "neoirc"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginRate := params.Config.LoginRateLimit
|
||||||
|
if loginRate <= 0 {
|
||||||
|
loginRate = ratelimit.DefaultRate
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBurst := params.Config.LoginRateBurst
|
||||||
|
if loginBurst <= 0 {
|
||||||
|
loginBurst = ratelimit.DefaultBurst
|
||||||
|
}
|
||||||
|
|
||||||
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
|
||||||
params: ¶ms,
|
params: ¶ms,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
@@ -70,6 +82,7 @@ func New(
|
|||||||
broker: broker.New(),
|
broker: broker.New(),
|
||||||
hashcashVal: hashcash.NewValidator(resource),
|
hashcashVal: hashcash.NewValidator(resource),
|
||||||
channelHashcash: hashcash.NewChannelValidator(),
|
channelHashcash: hashcash.NewChannelValidator(),
|
||||||
|
loginLimiter: ratelimit.New(loginRate, loginBurst),
|
||||||
stats: params.Stats,
|
stats: params.Stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +175,10 @@ func (hdlr *Handlers) stopCleanup() {
|
|||||||
if hdlr.cancelCleanup != nil {
|
if hdlr.cancelCleanup != nil {
|
||||||
hdlr.cancelCleanup()
|
hdlr.cancelCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hdlr.loginLimiter != nil {
|
||||||
|
hdlr.loginLimiter.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
func (hdlr *Handlers) cleanupLoop(ctx context.Context) {
|
||||||
|
|||||||
122
internal/ratelimit/ratelimit.go
Normal file
122
internal/ratelimit/ratelimit.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Package ratelimit provides per-IP rate limiting for HTTP endpoints.
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultRate is the default number of allowed requests per second.
|
||||||
|
DefaultRate = 1.0
|
||||||
|
|
||||||
|
// DefaultBurst is the default maximum burst size.
|
||||||
|
DefaultBurst = 5
|
||||||
|
|
||||||
|
// DefaultSweepInterval controls how often stale entries are pruned.
|
||||||
|
DefaultSweepInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// DefaultEntryTTL is how long an unused entry lives before eviction.
|
||||||
|
DefaultEntryTTL = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// entry tracks a per-IP rate limiter and when it was last used.
|
||||||
|
type entry struct {
|
||||||
|
limiter *rate.Limiter
|
||||||
|
lastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter manages per-key rate limiters with automatic cleanup
|
||||||
|
// of stale entries.
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*entry
|
||||||
|
rate rate.Limit
|
||||||
|
burst int
|
||||||
|
entryTTL time.Duration
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new per-key rate Limiter.
|
||||||
|
// The ratePerSec parameter sets how many requests per second are
|
||||||
|
// allowed per key. The burst parameter sets the maximum number of
|
||||||
|
// requests that can be made in a single burst.
|
||||||
|
func New(ratePerSec float64, burst int) *Limiter {
|
||||||
|
limiter := &Limiter{
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
entries: make(map[string]*entry),
|
||||||
|
rate: rate.Limit(ratePerSec),
|
||||||
|
burst: burst,
|
||||||
|
entryTTL: DefaultEntryTTL,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go limiter.sweepLoop()
|
||||||
|
|
||||||
|
return limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow reports whether a request from the given key should be
|
||||||
|
// allowed. It consumes one token from the key's rate limiter.
|
||||||
|
func (l *Limiter) Allow(key string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
ent, exists := l.entries[key]
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
ent = &entry{
|
||||||
|
limiter: rate.NewLimiter(l.rate, l.burst),
|
||||||
|
lastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
l.entries[key] = ent
|
||||||
|
} else {
|
||||||
|
ent.lastSeen = time.Now()
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
return ent.limiter.Allow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the background sweep goroutine.
|
||||||
|
func (l *Limiter) Stop() {
|
||||||
|
close(l.stopCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of tracked keys (for testing).
|
||||||
|
func (l *Limiter) Len() int {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
return len(l.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepLoop periodically removes entries that haven't been seen
|
||||||
|
// within the TTL.
|
||||||
|
func (l *Limiter) sweepLoop() {
|
||||||
|
ticker := time.NewTicker(DefaultSweepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
l.sweep()
|
||||||
|
case <-l.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweep removes stale entries.
|
||||||
|
func (l *Limiter) sweep() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-l.entryTTL)
|
||||||
|
|
||||||
|
for key, ent := range l.entries {
|
||||||
|
if ent.lastSeen.Before(cutoff) {
|
||||||
|
delete(l.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/ratelimit/ratelimit_test.go
Normal file
106
internal/ratelimit/ratelimit_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package ratelimit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewCreatesLimiter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if limiter == nil {
|
||||||
|
t.Fatal("expected non-nil limiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowWithinBurst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 3)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
if !limiter.Allow("192.168.1.1") {
|
||||||
|
t.Fatalf(
|
||||||
|
"request %d should be allowed within burst",
|
||||||
|
i+1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowExceedsBurst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Rate of 0 means no token replenishment, only burst.
|
||||||
|
limiter := ratelimit.New(0, 3)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("fourth request should be denied after burst exhausted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowSeparateKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Rate of 0, burst of 1 — only one request per key.
|
||||||
|
limiter := ratelimit.New(0, 1)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if !limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("first request for key A should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !limiter.Allow("10.0.0.2") {
|
||||||
|
t.Fatal("first request for key B should be allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.1") {
|
||||||
|
t.Fatal("second request for key A should be denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if limiter.Allow("10.0.0.2") {
|
||||||
|
t.Fatal("second request for key B should be denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLenTracksKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
defer limiter.Stop()
|
||||||
|
|
||||||
|
if limiter.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
limiter.Allow("10.0.0.2")
|
||||||
|
|
||||||
|
if limiter.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same key again should not increase count.
|
||||||
|
limiter.Allow("10.0.0.1")
|
||||||
|
|
||||||
|
if limiter.Len() != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", limiter.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopDoesNotPanic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
limiter := ratelimit.New(1.0, 5)
|
||||||
|
limiter.Stop()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user