feat: edge gateway service
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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{}
|
||||
Reference in New Issue
Block a user