Files
galaxy-game/gateway/internal/grpcapi/rate_limit.go
T
2026-04-02 19:18:42 +02:00

287 lines
9.2 KiB
Go

package grpcapi
import (
"context"
"errors"
"net"
"strings"
"galaxy/gateway/internal/config"
"galaxy/gateway/internal/ratelimit"
"galaxy/gateway/internal/session"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
const (
authenticatedGRPCBaseBucketKeyPrefix = "authenticated_grpc/"
authenticatedGRPCIPBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "ip="
authenticatedGRPCSessionBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "session="
authenticatedGRPCUserBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "user="
authenticatedGRPCMessageClassBucketKeySegment = authenticatedGRPCBaseBucketKeyPrefix + "message_class="
unknownAuthenticatedPeerIP = "unknown"
authenticatedRPCExecuteCommand = "ExecuteCommand"
authenticatedRPCSubscribeEvents = "SubscribeEvents"
)
var (
// ErrAuthenticatedPolicyDenied reports that the authenticated request was
// rejected by later edge policy after transport authenticity succeeded.
ErrAuthenticatedPolicyDenied = errors.New("authenticated request rejected by edge policy")
// ErrAuthenticatedPolicyUnavailable reports that authenticated policy could
// not be evaluated because its backing dependency is unavailable.
ErrAuthenticatedPolicyUnavailable = errors.New("authenticated request policy is unavailable")
)
// AuthenticatedRequestLimiter applies authenticated gRPC rate-limit policy to
// one concrete bucket key.
type AuthenticatedRequestLimiter interface {
// Reserve evaluates key under policy and reports whether the request may
// proceed immediately.
Reserve(key string, policy ratelimit.Policy) ratelimit.Decision
}
// AuthenticatedRequest describes the authenticated request metadata exposed to
// the edge-policy hook.
type AuthenticatedRequest struct {
// RPCMethod identifies the public gRPC method being processed.
RPCMethod string
// PeerIP is the transport peer IP derived from the gRPC connection.
PeerIP string
// MessageClass is the stable rate-limit and policy class. The gateway uses
// the full message_type literal because the v1 transport does not yet define
// a coarser authenticated class taxonomy.
MessageClass string
// Envelope contains the verified transport envelope fields used by later
// edge policy.
Envelope AuthenticatedRequestEnvelope
// Session contains the authenticated identity resolved from SessionCache.
Session session.Record
}
// AuthenticatedRequestEnvelope describes the verified request envelope fields
// exposed to the edge-policy hook.
type AuthenticatedRequestEnvelope struct {
// ProtocolVersion is the supported transport protocol version literal.
ProtocolVersion string
// DeviceSessionID is the authenticated device-session identifier.
DeviceSessionID string
// MessageType is the verified downstream routing key supplied by the client.
MessageType string
// TimestampMS is the client timestamp that already passed freshness checks.
TimestampMS int64
// RequestID is the authenticated transport request identifier.
RequestID string
// TraceID is the optional client-supplied correlation identifier.
TraceID string
}
// AuthenticatedRequestPolicy evaluates later authenticated edge policy after
// transport authenticity and rate-limit checks succeed.
type AuthenticatedRequestPolicy interface {
// Evaluate returns nil when the authenticated request may proceed. It should
// wrap ErrAuthenticatedPolicyDenied for stable reject mapping and
// ErrAuthenticatedPolicyUnavailable when its backing dependency is
// temporarily unavailable.
Evaluate(ctx context.Context, request AuthenticatedRequest) error
}
type authenticatedRateLimitService struct {
gatewayv1.UnimplementedEdgeGatewayServer
delegate gatewayv1.EdgeGatewayServer
limiter AuthenticatedRequestLimiter
policy AuthenticatedRequestPolicy
cfg config.AuthenticatedGRPCAntiAbuseConfig
}
// ExecuteCommand applies authenticated rate limits and edge policy before
// delegating to the configured service implementation.
func (s authenticatedRateLimitService) ExecuteCommand(ctx context.Context, req *gatewayv1.ExecuteCommandRequest) (*gatewayv1.ExecuteCommandResponse, error) {
if err := s.applyRateLimitsAndPolicy(ctx, authenticatedRPCExecuteCommand); err != nil {
return nil, err
}
return s.delegate.ExecuteCommand(ctx, req)
}
// SubscribeEvents applies authenticated rate limits and edge policy before
// delegating to the configured service implementation.
func (s authenticatedRateLimitService) SubscribeEvents(req *gatewayv1.SubscribeEventsRequest, stream grpc.ServerStreamingServer[gatewayv1.GatewayEvent]) error {
if err := s.applyRateLimitsAndPolicy(stream.Context(), authenticatedRPCSubscribeEvents); err != nil {
return err
}
return s.delegate.SubscribeEvents(req, stream)
}
// newAuthenticatedRateLimitService wraps delegate with the authenticated
// rate-limit and edge-policy gate.
func newAuthenticatedRateLimitService(delegate gatewayv1.EdgeGatewayServer, limiter AuthenticatedRequestLimiter, policy AuthenticatedRequestPolicy, cfg config.AuthenticatedGRPCAntiAbuseConfig) gatewayv1.EdgeGatewayServer {
return authenticatedRateLimitService{
delegate: delegate,
limiter: limiter,
policy: policy,
cfg: cfg,
}
}
func (s authenticatedRateLimitService) applyRateLimitsAndPolicy(ctx context.Context, rpcMethod string) error {
request, err := authenticatedRequestFromContext(ctx, rpcMethod)
if err != nil {
return err
}
if err := s.applyRateLimits(request); err != nil {
return err
}
if err := s.applyPolicy(ctx, request); err != nil {
return err
}
return nil
}
func (s authenticatedRateLimitService) applyRateLimits(request AuthenticatedRequest) error {
checks := []struct {
key string
policy config.AuthenticatedRateLimitConfig
}{
{
key: authenticatedGRPCIPBucketKey(request.PeerIP),
policy: s.cfg.IP,
},
{
key: authenticatedGRPCSessionBucketKey(request.Envelope.DeviceSessionID),
policy: s.cfg.Session,
},
{
key: authenticatedGRPCUserBucketKey(request.Session.UserID),
policy: s.cfg.User,
},
{
key: authenticatedGRPCMessageClassBucketKey(request.MessageClass),
policy: s.cfg.MessageClass,
},
}
for _, check := range checks {
decision := s.limiter.Reserve(check.key, ratelimit.Policy{
Requests: check.policy.Requests,
Window: check.policy.Window,
Burst: check.policy.Burst,
})
if !decision.Allowed {
return status.Error(codes.ResourceExhausted, "authenticated request rate limit exceeded")
}
}
return nil
}
func (s authenticatedRateLimitService) applyPolicy(ctx context.Context, request AuthenticatedRequest) error {
err := s.policy.Evaluate(ctx, request)
switch {
case err == nil:
return nil
case errors.Is(err, ErrAuthenticatedPolicyDenied):
return status.Error(codes.PermissionDenied, "authenticated request rejected by edge policy")
case errors.Is(err, ErrAuthenticatedPolicyUnavailable):
return status.Error(codes.Unavailable, "authenticated request policy is unavailable")
default:
return status.Error(codes.Internal, "authenticated request policy evaluation failed")
}
}
func authenticatedRequestFromContext(ctx context.Context, rpcMethod string) (AuthenticatedRequest, error) {
envelope, ok := parsedEnvelopeFromContext(ctx)
if !ok {
return AuthenticatedRequest{}, status.Error(codes.Internal, "authenticated request context is incomplete")
}
record, ok := resolvedSessionFromContext(ctx)
if !ok {
return AuthenticatedRequest{}, status.Error(codes.Internal, "authenticated request context is incomplete")
}
return AuthenticatedRequest{
RPCMethod: rpcMethod,
PeerIP: peerIPFromContext(ctx),
MessageClass: authenticatedMessageClass(envelope.MessageType),
Envelope: AuthenticatedRequestEnvelope{
ProtocolVersion: envelope.ProtocolVersion,
DeviceSessionID: envelope.DeviceSessionID,
MessageType: envelope.MessageType,
TimestampMS: envelope.TimestampMS,
RequestID: envelope.RequestID,
TraceID: envelope.TraceID,
},
Session: record,
}, nil
}
func authenticatedGRPCIPBucketKey(peerIP string) string {
return authenticatedGRPCIPBucketKeySegment + peerIP
}
func authenticatedGRPCSessionBucketKey(deviceSessionID string) string {
return authenticatedGRPCSessionBucketKeySegment + deviceSessionID
}
func authenticatedGRPCUserBucketKey(userID string) string {
return authenticatedGRPCUserBucketKeySegment + userID
}
func authenticatedGRPCMessageClassBucketKey(messageClass string) string {
return authenticatedGRPCMessageClassBucketKeySegment + messageClass
}
func authenticatedMessageClass(messageType string) string {
return messageType
}
func peerIPFromContext(ctx context.Context) string {
peerInfo, ok := peer.FromContext(ctx)
if !ok || peerInfo.Addr == nil {
return unknownAuthenticatedPeerIP
}
value := strings.TrimSpace(peerInfo.Addr.String())
if value == "" {
return unknownAuthenticatedPeerIP
}
host, _, err := net.SplitHostPort(value)
if err == nil && host != "" {
return host
}
return value
}
type noopAuthenticatedRequestPolicy struct{}
func (noopAuthenticatedRequestPolicy) Evaluate(context.Context, AuthenticatedRequest) error {
return nil
}
var _ gatewayv1.EdgeGatewayServer = authenticatedRateLimitService{}