mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-17 02:50:00 +00:00
194 lines
8.0 KiB
Go
194 lines
8.0 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package router
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/MeshCore-Beacon/beacon-server/internal/config"
|
|
)
|
|
|
|
func TestDefaultAPIRateLimit(t *testing.T) {
|
|
handler := New(nil, nil, nil, Options{MaxConnsPerIP: 5, MaxConnectsPerMinute: 1000, RateLimit: config.Resolve(&config.Config{}).RateLimit})
|
|
for i := 0; i <= 300; i++ {
|
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/brokers", nil)
|
|
request.RemoteAddr = "198.51.100.1:1234"
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, request)
|
|
want := http.StatusOK
|
|
if i == 300 {
|
|
want = http.StatusTooManyRequests
|
|
}
|
|
if response.Code != want {
|
|
t.Fatalf("request %d: got %d, want %d", i+1, response.Code, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func rateRequest(handler http.Handler, path, peer, headerIP string) *httptest.ResponseRecorder {
|
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
request.RemoteAddr = peer
|
|
request.Header.Set("Origin", "https://example.test")
|
|
request.Header.Set("X-Real-IP", headerIP)
|
|
request.Header.Set("X-Forwarded-For", headerIP)
|
|
request.Header.Set("True-Client-IP", headerIP)
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, request)
|
|
return response
|
|
}
|
|
|
|
func TestAPIRateLimitContractAndLogging(t *testing.T) {
|
|
var logs bytes.Buffer
|
|
previous, writer, flags := slog.Default(), log.Writer(), log.Flags()
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(previous); log.SetOutput(writer); log.SetFlags(flags) })
|
|
proxy := config.ServerConfig{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}}
|
|
handler := New(nil, nil, nil, Options{MaxConnsPerIP: 5, MaxConnectsPerMinute: 1000, Server: proxy, RateLimit: config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 2, Burst: 10}})
|
|
for _, path := range []string{"/api/v1/brokers", "/api/v1/packets?limit=0"} {
|
|
response := rateRequest(handler, path, "127.0.0.1:1234", "198.51.100.25")
|
|
if response.Code != http.StatusOK && response.Code != http.StatusBadRequest {
|
|
t.Fatalf("initial request: %d", response.Code)
|
|
}
|
|
}
|
|
response := rateRequest(handler, "/api/v1/brokers", "127.0.0.1:1234", "198.51.100.25")
|
|
var body struct {
|
|
Error struct{ Code, Message string }
|
|
}
|
|
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.Code != http.StatusTooManyRequests || body.Error.Code != "rate_limited" || body.Error.Message == "" {
|
|
t.Fatalf("wrong rate-limit response: %d %s", response.Code, response.Body.String())
|
|
}
|
|
if response.Header().Get("Content-Type") != "application/json" || response.Header().Get("Retry-After") != "60" || !strings.EqualFold(response.Header().Get("Access-Control-Expose-Headers"), "Retry-After") || response.Header().Get("Access-Control-Allow-Origin") == "" {
|
|
t.Fatalf("missing JSON/backoff/CORS headers: %v", response.Header())
|
|
}
|
|
rejections := 0
|
|
for _, line := range bytes.Split(bytes.TrimSpace(logs.Bytes()), []byte("\n")) {
|
|
var record map[string]any
|
|
if err := json.Unmarshal(line, &record); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if record["status"] == float64(http.StatusTooManyRequests) {
|
|
rejections++
|
|
if record["level"] != "WARN" || record["client_ip"] != "198.51.100.25" || record["path"] != "/api/v1/*" {
|
|
t.Fatalf("wrong rejection log: %v", record)
|
|
}
|
|
}
|
|
}
|
|
if rejections != 1 {
|
|
t.Fatalf("got %d rejection logs, want 1", rejections)
|
|
}
|
|
}
|
|
|
|
func TestAPIRateLimitClientIdentity(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, firstPeer, secondPeer, firstHeader, secondHeader string
|
|
trusted, shared bool
|
|
}{
|
|
{"untrusted header rotation", "198.51.100.1:1", "198.51.100.1:2", "192.0.2.1", "192.0.2.2", false, true},
|
|
{"independent peers", "198.51.100.1:1", "198.51.100.2:1", "192.0.2.1", "192.0.2.1", false, false},
|
|
{"trusted independent clients", "127.0.0.1:1", "127.0.0.1:2", "198.51.100.1", "198.51.100.2", true, false},
|
|
{"trusted shared client", "127.0.0.1:1", "127.0.0.1:2", "198.51.100.1", "198.51.100.1", true, true},
|
|
{"IPv6 shared prefix", "[2001:db8:1::1]:1", "[2001:db8:1::2]:2", "", "", false, true},
|
|
{"IPv6 independent prefixes", "[2001:db8:1::1]:1", "[2001:db8:2::1]:2", "", "", false, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
proxy := config.ServerConfig{}
|
|
if tc.trusted {
|
|
proxy.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}
|
|
}
|
|
handler := New(nil, nil, nil, Options{MaxConnsPerIP: 5, MaxConnectsPerMinute: 1000, Server: proxy, RateLimit: config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 1, Burst: 1}})
|
|
if response := rateRequest(handler, "/api/v1/brokers", tc.firstPeer, tc.firstHeader); response.Code != http.StatusOK {
|
|
t.Fatalf("first client: %d", response.Code)
|
|
}
|
|
want := http.StatusOK
|
|
if tc.shared {
|
|
want = http.StatusTooManyRequests
|
|
}
|
|
if response := rateRequest(handler, "/api/v1/brokers", tc.secondPeer, tc.secondHeader); response.Code != want {
|
|
t.Fatalf("second client: %d, want %d", response.Code, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPIRateLimitWindowsAndExclusions(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
handler := New(nil, nil, nil, Options{MaxConnsPerIP: 5, MaxConnectsPerMinute: 1000, RateLimit: config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 4, Burst: 2}})
|
|
request := func() *httptest.ResponseRecorder {
|
|
return rateRequest(handler, "/api/v1/brokers", "198.51.100.1:1", "")
|
|
}
|
|
preflight := httptest.NewRequest(http.MethodOptions, "/api/v1/brokers", nil)
|
|
preflight.RemoteAddr = "198.51.100.1:1"
|
|
preflight.Header.Set("Origin", "https://example.test")
|
|
preflight.Header.Set("Access-Control-Request-Method", "GET")
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, preflight)
|
|
if response.Code != http.StatusOK || request().Code != http.StatusOK || request().Code != http.StatusOK {
|
|
t.Fatal("preflight consumed the API budget")
|
|
}
|
|
if response := request(); response.Code != http.StatusTooManyRequests || response.Header().Get("Retry-After") != "1" {
|
|
t.Fatalf("burst limit: %d %v", response.Code, response.Header())
|
|
}
|
|
time.Sleep(2 * time.Second)
|
|
if request().Code != http.StatusOK {
|
|
t.Fatal("burst did not recover")
|
|
}
|
|
if response := request(); response.Code != http.StatusTooManyRequests || response.Header().Get("Retry-After") != "60" {
|
|
t.Fatalf("minute limit: %d %v", response.Code, response.Header())
|
|
}
|
|
response = httptest.NewRecorder()
|
|
handler.ServeHTTP(response, preflight)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatal("exhausted API budget blocked preflight")
|
|
}
|
|
if response := rateRequest(handler, "/swagger", "198.51.100.1:1", ""); response.Code != http.StatusMovedPermanently {
|
|
t.Fatalf("Swagger shares the API budget: %d", response.Code)
|
|
}
|
|
time.Sleep(2 * time.Minute)
|
|
if request().Code != http.StatusOK {
|
|
t.Fatal("minute budget did not recover")
|
|
}
|
|
})
|
|
handler := New(nil, nil, nil, Options{MaxConnsPerIP: 5, MaxConnectsPerMinute: 1000, RateLimit: config.ResolvedRateLimitConfig{Enabled: false, RequestsPerMinute: 1, Burst: 1}})
|
|
for range 5 {
|
|
if response := rateRequest(handler, "/api/v1/brokers", "198.51.100.1:1", ""); response.Code != http.StatusOK || response.Header().Get("Retry-After") != "" {
|
|
t.Fatal("disabled limiter still applied")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIRateLimitConcurrentRequests(t *testing.T) {
|
|
handler := New(nil, nil, nil, Options{MaxConnsPerIP: 5, MaxConnectsPerMinute: 1000, RateLimit: config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 5, Burst: 5}})
|
|
var accepted, rejected atomic.Int32
|
|
var group sync.WaitGroup
|
|
for range 20 {
|
|
group.Go(func() {
|
|
switch rateRequest(handler, "/api/v1/brokers", "198.51.100.1:1", "").Code {
|
|
case http.StatusOK:
|
|
accepted.Add(1)
|
|
case http.StatusTooManyRequests:
|
|
rejected.Add(1)
|
|
}
|
|
})
|
|
}
|
|
group.Wait()
|
|
if accepted.Load() != 5 || rejected.Load() != 15 {
|
|
t.Fatalf("concurrent budget: accepted=%d rejected=%d", accepted.Load(), rejected.Load())
|
|
}
|
|
}
|