which runs a plain TCP server for receiving websocket hub clients, with server config, CSRF and rate limit middleware. - internal/websocket: Hub pushing messages to WebSocket clients (ServeHTTP, example.com/ws upgrade with optional CSRF check) and plain TCP clients (ServeTCP, newline-delimited JSON envelopes); ping/pong keepalive, per-client message rate limit, slow consumer handling, graceful Shutdown, debug logging of messages sent and received - internal/websocket/tcpserver: standalone TCP server for a hub, with accept back-off, a per-IP connection limit (server.websocket.connection_limit) and graceful shutdown - internal/cli/wsserve: `wsserve` command running the TCP server, with --listen/--host/--port overrides - internal/middleware: keyed token bucket rate limiter, real client IP from trusted proxies - internal/httpx: RFC 9457 problem responses, request IDs in context - internal/config: server section (host, port, shutdown timeout, rate limiter idle TTL, websocket); util.ByteSize settings (e.g. "32KiB"); lists of sections as single keys; empty lists stored as nil; isExplicitlySet for printLoadedConfig; JobSearchConfigSource renamed to JSCSourcesSource; TestSections checks every section's methods - internal/util: ByteSize, parsed and formatted with go-humanize
145 lines
3.5 KiB
Go
145 lines
3.5 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"hash/maphash"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/httpx"
|
|
)
|
|
|
|
// Limiter is the rate limiting hook. Implementations decide whether a request
|
|
// identified by key may proceed. Swap in a Redis/memcached-backed limiter for
|
|
// multi-instance deployments by implementing this interface.
|
|
type Limiter interface {
|
|
Allow(key string) (allowed bool, retryAfter time.Duration)
|
|
}
|
|
|
|
// LimiterFunc adapts a function to the Limiter interface.
|
|
type LimiterFunc func(key string) (bool, time.Duration)
|
|
|
|
func (f LimiterFunc) Allow(key string) (bool, time.Duration) { return f(key) }
|
|
|
|
// KeyFunc derives the rate limit key from a request.
|
|
type KeyFunc func(*http.Request) string
|
|
|
|
// KeyByIP keys on the real client IP (after proxy processing).
|
|
func KeyByIP(r *http.Request) string { return ClientIP(r) }
|
|
|
|
// RateLimit turns a Limiter into middleware. Rejected requests get 429 with
|
|
// a Retry-After header. A nil limiter yields a pass-through middleware.
|
|
func RateLimit(l Limiter, key KeyFunc) func(http.Handler) http.Handler {
|
|
if l == nil {
|
|
return func(next http.Handler) http.Handler { return next }
|
|
}
|
|
if key == nil {
|
|
key = KeyByIP
|
|
}
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ok, retry := l.Allow(key(r))
|
|
if !ok {
|
|
secs := int(math.Ceil(retry.Seconds()))
|
|
if secs < 1 {
|
|
secs = 1
|
|
}
|
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
|
httpx.WriteProblem(w, r, http.StatusTooManyRequests, "rate limit exceeded")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// KeyedLimiter is an in-memory token bucket per key, sharded to reduce lock
|
|
// contention, with idle-entry eviction.
|
|
type KeyedLimiter struct {
|
|
limit rate.Limit
|
|
burst int
|
|
ttl time.Duration
|
|
seed maphash.Seed
|
|
shards [32]limiterShard
|
|
}
|
|
|
|
type limiterShard struct {
|
|
mu sync.Mutex
|
|
entries map[string]*limiterEntry
|
|
}
|
|
|
|
type limiterEntry struct {
|
|
lim *rate.Limiter
|
|
last time.Time
|
|
}
|
|
|
|
// NewKeyedLimiter creates a limiter allowing rps sustained requests per
|
|
// second with the given burst. A janitor goroutine evicts entries idle for
|
|
// longer than ttl until ctx is cancelled.
|
|
func NewKeyedLimiter(ctx context.Context, rps float64, burst int, ttl time.Duration) *KeyedLimiter {
|
|
k := &KeyedLimiter{limit: rate.Limit(rps), burst: burst, ttl: ttl, seed: maphash.MakeSeed()}
|
|
for i := range k.shards {
|
|
k.shards[i].entries = make(map[string]*limiterEntry)
|
|
}
|
|
if ttl > 0 {
|
|
go k.janitor(ctx)
|
|
}
|
|
return k
|
|
}
|
|
|
|
func (k *KeyedLimiter) shard(key string) *limiterShard {
|
|
return &k.shards[maphash.String(k.seed, key)%uint64(len(k.shards))]
|
|
}
|
|
|
|
// Allow implements Limiter.
|
|
func (k *KeyedLimiter) Allow(key string) (bool, time.Duration) {
|
|
now := time.Now()
|
|
s := k.shard(key)
|
|
s.mu.Lock()
|
|
e := s.entries[key]
|
|
if e == nil {
|
|
e = &limiterEntry{lim: rate.NewLimiter(k.limit, k.burst)}
|
|
s.entries[key] = e
|
|
}
|
|
e.last = now
|
|
lim := e.lim
|
|
s.mu.Unlock()
|
|
|
|
res := lim.ReserveN(now, 1)
|
|
if !res.OK() {
|
|
return false, time.Second
|
|
}
|
|
if d := res.DelayFrom(now); d > 0 {
|
|
res.CancelAt(now)
|
|
return false, d
|
|
}
|
|
return true, 0
|
|
}
|
|
|
|
func (k *KeyedLimiter) janitor(ctx context.Context) {
|
|
t := time.NewTicker(k.ttl / 2)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-t.C:
|
|
for i := range k.shards {
|
|
s := &k.shards[i]
|
|
s.mu.Lock()
|
|
for key, e := range s.entries {
|
|
if now.Sub(e.last) > k.ttl {
|
|
delete(s.entries, key)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}
|