R3: gateway edge hardening — body cap, h2c sizing, rate-limit observability
- GATEWAY_MAX_BODY_BYTES (1 MiB): connect WithReadMaxBytes + http.MaxBytesReader
on the public mux; explicit http2.Server MaxConcurrentStreams/IdleTimeout and
an http.Server ReadHeaderTimeout (R2 report follow-up).
- gateway_rate_limited_total{class} counter, Debug per rejection, a rejection
tracker drained every 30 s into a Warn summary per key and a report POST to
/api/v1/internal/ratelimit/report (feeds the admin view + auto-flag).
- The dead AdminPerMinute/AdminBurst policy now guards the /_gm mount (429),
ahead of its Basic-Auth.
- resolve() logs the cause of infra session-resolve failures at Warn (the
transient unauthenticated dips from the R2 run); unknown tokens stay silent.
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
pushv1 "scrabble/pkg/proto/push/v1"
|
||||
)
|
||||
|
||||
@@ -124,3 +125,15 @@ func parseAPIError(status int, data []byte) *APIError {
|
||||
func (c *Client) SubscribePush(ctx context.Context, gatewayID string) (grpc.ServerStreamingClient[pushv1.Event], error) {
|
||||
return c.push.Subscribe(ctx, &pushv1.SubscribeRequest{GatewayId: gatewayID})
|
||||
}
|
||||
|
||||
// ReportRateLimited posts the gateway's periodic rate-limiter rejection summary
|
||||
// to the backend, which feeds the admin console's throttled view and the
|
||||
// high-rate auto-flag. The endpoint carries no user identity: like
|
||||
// sessions/resolve it rides the trusted internal segment (R3).
|
||||
func (c *Client) ReportRateLimited(ctx context.Context, windowSeconds int, entries []ratelimit.Rejection) error {
|
||||
body := struct {
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
Entries []ratelimit.Rejection `json:"entries"`
|
||||
}{WindowSeconds: windowSeconds, Entries: entries}
|
||||
return c.do(ctx, http.MethodPost, "/api/v1/internal/ratelimit/report", "", "", body, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package backendclient_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
)
|
||||
|
||||
// TestReportRateLimited verifies the rejection report reaches the backend's
|
||||
// internal endpoint with the agreed JSON shape and no user identity.
|
||||
func TestReportRateLimited(t *testing.T) {
|
||||
var got struct {
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
Entries []ratelimit.Rejection `json:"entries"`
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/internal/ratelimit/report" {
|
||||
t.Errorf("call = %s %s, want POST /api/v1/internal/ratelimit/report", r.Method, r.URL.Path)
|
||||
}
|
||||
if uid := r.Header.Get("X-User-ID"); uid != "" {
|
||||
t.Errorf("X-User-ID = %q, want empty", uid)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode report: %v", err)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
entries := []ratelimit.Rejection{{Class: "user", Key: "u-1", Rejected: 5}}
|
||||
if err := c.ReportRateLimited(context.Background(), 30, entries); err != nil {
|
||||
t.Fatalf("ReportRateLimited: %v", err)
|
||||
}
|
||||
if got.WindowSeconds != 30 || len(got.Entries) != 1 || got.Entries[0] != entries[0] {
|
||||
t.Fatalf("backend received %+v, want window 30 + %+v", got, entries[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user