add rate limiting configuration for authenticated and unauthenticated requests
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s
This commit is contained in:
12
.env.example
12
.env.example
@@ -11,3 +11,15 @@ DATABASE_NAME=emly
|
|||||||
# API Keys
|
# API Keys
|
||||||
API_KEY=key-one
|
API_KEY=key-one
|
||||||
ADMIN_KEY=admin-key-one
|
ADMIN_KEY=admin-key-one
|
||||||
|
|
||||||
|
# Rate Limiting (unauthenticated: no X-API-Key / X-Admin-Key)
|
||||||
|
RL_UNAUTH_MAX_REQS=10
|
||||||
|
RL_UNAUTH_WINDOW=5m
|
||||||
|
RL_UNAUTH_MAX_FAILS=5
|
||||||
|
RL_UNAUTH_BAN_DUR=15m
|
||||||
|
|
||||||
|
# Rate Limiting (authenticated: X-API-Key or X-Admin-Key present)
|
||||||
|
RL_AUTH_MAX_REQS=100
|
||||||
|
RL_AUTH_WINDOW=1m
|
||||||
|
RL_AUTH_MAX_FAILS=20
|
||||||
|
RL_AUTH_BAN_DUR=5m
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -10,10 +10,7 @@ require (
|
|||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
|
||||||
golang.org/x/time v0.15.0 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.1 // indirect
|
filippo.io/edwards25519 v1.1.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -23,5 +23,3 @@ golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
|||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
|
||||||
|
|||||||
@@ -5,8 +5,20 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type RateLimitConfig struct {
|
||||||
|
UnauthMaxReqs int
|
||||||
|
UnauthWindow time.Duration
|
||||||
|
UnauthMaxFails int
|
||||||
|
UnauthBanDur time.Duration
|
||||||
|
AuthMaxReqs int
|
||||||
|
AuthWindow time.Duration
|
||||||
|
AuthMaxFails int
|
||||||
|
AuthBanDur time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
DSN string
|
DSN string
|
||||||
@@ -16,6 +28,7 @@ type Config struct {
|
|||||||
MaxOpenConns int
|
MaxOpenConns int
|
||||||
MaxIdleConns int
|
MaxIdleConns int
|
||||||
ConnMaxLifetime int
|
ConnMaxLifetime int
|
||||||
|
RateLimit RateLimitConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -85,5 +98,33 @@ func load() *Config {
|
|||||||
MaxOpenConns: maxOpenConns,
|
MaxOpenConns: maxOpenConns,
|
||||||
MaxIdleConns: maxIdleConns,
|
MaxIdleConns: maxIdleConns,
|
||||||
ConnMaxLifetime: connMaxLifetime,
|
ConnMaxLifetime: connMaxLifetime,
|
||||||
|
RateLimit: RateLimitConfig{
|
||||||
|
UnauthMaxReqs: envInt("RL_UNAUTH_MAX_REQS", 10),
|
||||||
|
UnauthWindow: envDuration("RL_UNAUTH_WINDOW", 5*time.Minute),
|
||||||
|
UnauthMaxFails: envInt("RL_UNAUTH_MAX_FAILS", 5),
|
||||||
|
UnauthBanDur: envDuration("RL_UNAUTH_BAN_DUR", 15*time.Minute),
|
||||||
|
AuthMaxReqs: envInt("RL_AUTH_MAX_REQS", 100),
|
||||||
|
AuthWindow: envDuration("RL_AUTH_WINDOW", time.Minute),
|
||||||
|
AuthMaxFails: envInt("RL_AUTH_MAX_FAILS", 20),
|
||||||
|
AuthBanDur: envDuration("RL_AUTH_BAN_DUR", 5*time.Minute),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envInt(key string, fallback int) int {
|
||||||
|
if s := os.Getenv(key); s != "" {
|
||||||
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDuration(key string, fallback time.Duration) time.Duration {
|
||||||
|
if s := os.Getenv(key); s != "" {
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,54 +1,70 @@
|
|||||||
// middleware/ratelimit.go
|
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
"emly-api-go/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type visitor struct {
|
type limitConfig struct {
|
||||||
limiter *rate.Limiter
|
maxReqs int
|
||||||
lastSeen time.Time
|
window time.Duration
|
||||||
|
maxFails int
|
||||||
|
banDur time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type ipState struct {
|
||||||
|
count int
|
||||||
|
windowStart time.Time
|
||||||
failures int
|
failures int
|
||||||
|
lastSeen time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
visitors map[string]*visitor
|
unauthVisitors map[string]*ipState
|
||||||
banned sync.Map // ip -> unban time
|
authVisitors map[string]*ipState
|
||||||
|
banned sync.Map // ip -> unban time (shared)
|
||||||
|
|
||||||
// config
|
unauthCfg limitConfig
|
||||||
rps rate.Limit // richieste/sec normali
|
authCfg limitConfig
|
||||||
burst int
|
|
||||||
maxFails int // quanti 429 prima del ban
|
|
||||||
banDur time.Duration // durata ban
|
|
||||||
cleanEvery time.Duration
|
cleanEvery time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRateLimiter(rps float64, burst, maxFails int, banDur time.Duration) *RateLimiter {
|
// NewRateLimiter creates a two-tier rate limiter configured from cfg:
|
||||||
|
// - Unauthenticated (no X-API-Key / X-Admin-Key): RL_UNAUTH_* env vars
|
||||||
|
// - Authenticated (X-API-Key or X-Admin-Key present): RL_AUTH_* env vars
|
||||||
|
func NewRateLimiter(cfg *config.Config) *RateLimiter {
|
||||||
rl := &RateLimiter{
|
rl := &RateLimiter{
|
||||||
visitors: make(map[string]*visitor),
|
unauthVisitors: make(map[string]*ipState),
|
||||||
rps: rate.Limit(rps),
|
authVisitors: make(map[string]*ipState),
|
||||||
burst: burst,
|
unauthCfg: limitConfig{
|
||||||
maxFails: maxFails,
|
maxReqs: cfg.RateLimit.UnauthMaxReqs,
|
||||||
banDur: banDur,
|
window: cfg.RateLimit.UnauthWindow,
|
||||||
cleanEvery: 5 * time.Minute,
|
maxFails: cfg.RateLimit.UnauthMaxFails,
|
||||||
|
banDur: cfg.RateLimit.UnauthBanDur,
|
||||||
|
},
|
||||||
|
authCfg: limitConfig{
|
||||||
|
maxReqs: cfg.RateLimit.AuthMaxReqs,
|
||||||
|
window: cfg.RateLimit.AuthWindow,
|
||||||
|
maxFails: cfg.RateLimit.AuthMaxFails,
|
||||||
|
banDur: cfg.RateLimit.AuthBanDur,
|
||||||
|
},
|
||||||
|
cleanEvery: 10 * time.Minute,
|
||||||
}
|
}
|
||||||
go rl.cleanupLoop()
|
go rl.cleanupLoop()
|
||||||
return rl
|
return rl
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) getIP(r *http.Request) string {
|
func (rl *RateLimiter) getIP(r *http.Request) string {
|
||||||
// Rispetta X-Forwarded-For se dietro Traefik/proxy
|
|
||||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||||
// Prendi il primo IP (quello del client originale)
|
|
||||||
if h, _, err := net.SplitHostPort(ip); err == nil {
|
if h, _, err := net.SplitHostPort(ip); err == nil {
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
@@ -58,62 +74,84 @@ func (rl *RateLimiter) getIP(r *http.Request) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) getVisitor(ip string) *visitor {
|
func (rl *RateLimiter) isAuthenticated(r *http.Request) bool {
|
||||||
|
return r.Header.Get("X-API-Key") != "" || r.Header.Get("X-Admin-Key") != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// record increments the counter for the IP and returns whether the limit was
|
||||||
|
// exceeded, the current failure count, and whether the IP should be banned.
|
||||||
|
func (rl *RateLimiter) record(ip string, auth bool) (exceeded bool, failures int, shouldBan bool, banDur time.Duration) {
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
defer rl.mu.Unlock()
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
v, ok := rl.visitors[ip]
|
var visitors map[string]*ipState
|
||||||
|
var cfg limitConfig
|
||||||
|
if auth {
|
||||||
|
visitors = rl.authVisitors
|
||||||
|
cfg = rl.authCfg
|
||||||
|
} else {
|
||||||
|
visitors = rl.unauthVisitors
|
||||||
|
cfg = rl.unauthCfg
|
||||||
|
}
|
||||||
|
|
||||||
|
v, ok := visitors[ip]
|
||||||
if !ok {
|
if !ok {
|
||||||
v = &visitor{
|
v = &ipState{windowStart: time.Now()}
|
||||||
limiter: rate.NewLimiter(rl.rps, rl.burst),
|
visitors[ip] = v
|
||||||
}
|
}
|
||||||
rl.visitors[ip] = v
|
|
||||||
|
now := time.Now()
|
||||||
|
v.lastSeen = now
|
||||||
|
|
||||||
|
// Roll the window if expired
|
||||||
|
if now.Sub(v.windowStart) >= cfg.window {
|
||||||
|
v.count = 0
|
||||||
|
v.windowStart = now
|
||||||
}
|
}
|
||||||
v.lastSeen = time.Now()
|
|
||||||
return v
|
v.count++
|
||||||
|
|
||||||
|
if v.count > cfg.maxReqs {
|
||||||
|
v.failures++
|
||||||
|
return true, v.failures, v.failures >= cfg.maxFails, cfg.banDur
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legitimate request within limit — reset failure streak
|
||||||
|
v.failures = 0
|
||||||
|
return false, 0, false, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) Handler(next http.Handler) http.Handler {
|
func (rl *RateLimiter) Handler(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := rl.getIP(r)
|
ip := rl.getIP(r)
|
||||||
|
|
||||||
// Controlla ban attivo
|
// Check active ban
|
||||||
if unbanAt, banned := rl.banned.Load(ip); banned {
|
if unbanAt, banned := rl.banned.Load(ip); banned {
|
||||||
if time.Now().Before(unbanAt.(time.Time)) {
|
if time.Now().Before(unbanAt.(time.Time)) {
|
||||||
w.Header().Set("Retry-After", unbanAt.(time.Time).Format(time.RFC1123))
|
w.Header().Set("Retry-After", unbanAt.(time.Time).Format(time.RFC1123))
|
||||||
http.Error(w, "too many requests - temporarily banned", http.StatusForbidden)
|
http.Error(w, "too many requests - temporarily banned", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Ban scaduto
|
|
||||||
rl.banned.Delete(ip)
|
rl.banned.Delete(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
v := rl.getVisitor(ip)
|
auth := rl.isAuthenticated(r)
|
||||||
|
exceeded, failures, shouldBan, banDur := rl.record(ip, auth)
|
||||||
|
|
||||||
if !v.limiter.Allow() {
|
if exceeded {
|
||||||
rl.mu.Lock()
|
if shouldBan {
|
||||||
v.failures++
|
unbanAt := time.Now().Add(banDur)
|
||||||
fails := v.failures
|
|
||||||
rl.mu.Unlock()
|
|
||||||
|
|
||||||
if fails >= rl.maxFails {
|
|
||||||
unbanAt := time.Now().Add(rl.banDur)
|
|
||||||
rl.banned.Store(ip, unbanAt)
|
rl.banned.Store(ip, unbanAt)
|
||||||
// Opzionale: loga il ban
|
log.Printf("[RATE-LIMIT] IP %s banned until %s (path: %s, auth: %v)", ip, unbanAt.Format(time.RFC1123), r.URL.Path, auth)
|
||||||
w.Header().Set("Retry-After", unbanAt.Format(time.RFC1123))
|
w.Header().Set("Retry-After", unbanAt.Format(time.RFC1123))
|
||||||
http.Error(w, "banned", http.StatusForbidden)
|
http.Error(w, "banned", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[RATE-LIMIT] IP %s exceeded limit — violation %d (path: %s, auth: %v)", ip, failures, r.URL.Path, auth)
|
||||||
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
|
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset failures su richiesta legittima
|
|
||||||
rl.mu.Lock()
|
|
||||||
v.failures = 0
|
|
||||||
rl.mu.Unlock()
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,13 +161,17 @@ func (rl *RateLimiter) cleanupLoop() {
|
|||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
for ip, v := range rl.visitors {
|
for ip, v := range rl.unauthVisitors {
|
||||||
if time.Since(v.lastSeen) > 10*time.Minute {
|
if time.Since(v.lastSeen) > rl.unauthCfg.window*2 {
|
||||||
delete(rl.visitors, ip)
|
delete(rl.unauthVisitors, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ip, v := range rl.authVisitors {
|
||||||
|
if time.Since(v.lastSeen) > rl.authCfg.window*2 {
|
||||||
|
delete(rl.authVisitors, ip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl.mu.Unlock()
|
rl.mu.Unlock()
|
||||||
// Pulisci anche i ban scaduti
|
|
||||||
rl.banned.Range(func(k, v any) bool {
|
rl.banned.Range(func(k, v any) bool {
|
||||||
if time.Now().After(v.(time.Time)) {
|
if time.Now().After(v.(time.Time)) {
|
||||||
rl.banned.Delete(k)
|
rl.banned.Delete(k)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package v1
|
|||||||
import (
|
import (
|
||||||
emlyMiddleware "emly-api-go/internal/middleware"
|
emlyMiddleware "emly-api-go/internal/middleware"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"emly-api-go/internal/config"
|
||||||
"emly-api-go/internal/handlers"
|
"emly-api-go/internal/handlers"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -17,12 +17,7 @@ import (
|
|||||||
func NewRouter(db *sqlx.DB) http.Handler {
|
func NewRouter(db *sqlx.DB) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
rl := emlyMiddleware.NewRateLimiter(
|
rl := emlyMiddleware.NewRateLimiter(config.Load())
|
||||||
5, // 5 req/sec per IP
|
|
||||||
10, // burst fino a 10
|
|
||||||
20, // ban dopo 20 violazioni
|
|
||||||
15*time.Minute, // ban di 15 minuti
|
|
||||||
)
|
|
||||||
|
|
||||||
r.Use(rl.Handler)
|
r.Use(rl.Handler)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package v2
|
|||||||
import (
|
import (
|
||||||
emlyMiddleware "emly-api-go/internal/middleware"
|
emlyMiddleware "emly-api-go/internal/middleware"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"emly-api-go/internal/config"
|
||||||
"emly-api-go/internal/handlers"
|
"emly-api-go/internal/handlers"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -17,12 +17,7 @@ import (
|
|||||||
func NewRouter(db *sqlx.DB) http.Handler {
|
func NewRouter(db *sqlx.DB) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
rl := emlyMiddleware.NewRateLimiter(
|
rl := emlyMiddleware.NewRateLimiter(config.Load())
|
||||||
5, // 5 req/sec per IP
|
|
||||||
10, // burst fino a 10
|
|
||||||
20, // ban dopo 20 violazioni
|
|
||||||
15*time.Minute, // ban di 15 minuti
|
|
||||||
)
|
|
||||||
|
|
||||||
r.Use(rl.Handler)
|
r.Use(rl.Handler)
|
||||||
|
|
||||||
|
|||||||
11
main.go
11
main.go
@@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/httprate"
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
@@ -51,15 +50,7 @@ func main() {
|
|||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Timeout(30 * time.Second))
|
r.Use(middleware.Timeout(30 * time.Second))
|
||||||
|
|
||||||
// Global rate limit to 100 requests per minute
|
rl := emlyMiddleware.NewRateLimiter(cfg)
|
||||||
r.Use(httprate.LimitByIP(100, time.Minute))
|
|
||||||
|
|
||||||
rl := emlyMiddleware.NewRateLimiter(
|
|
||||||
5, // 5 req/sec per IP
|
|
||||||
10, // burst fino a 10
|
|
||||||
20, // ban dopo 20 violazioni
|
|
||||||
30*time.Minute, // ban di 15 minuti
|
|
||||||
)
|
|
||||||
|
|
||||||
r.Use(rl.Handler)
|
r.Use(rl.Handler)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user