feat: edge gateway service
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
// Package grpcapi exposes the authenticated gRPC surface of the gateway.
|
||||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"galaxy/gateway/internal/authn"
|
||||
"galaxy/gateway/internal/clock"
|
||||
"galaxy/gateway/internal/config"
|
||||
"galaxy/gateway/internal/downstream"
|
||||
"galaxy/gateway/internal/push"
|
||||
"galaxy/gateway/internal/ratelimit"
|
||||
"galaxy/gateway/internal/replay"
|
||||
"galaxy/gateway/internal/session"
|
||||
"galaxy/gateway/internal/telemetry"
|
||||
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// ServerDependencies describes the optional collaborators used by the
|
||||
// authenticated gRPC server. The zero value is valid and keeps the process
|
||||
// runnable with the built-in unimplemented service stub.
|
||||
type ServerDependencies struct {
|
||||
// Service optionally handles the post-bootstrap SubscribeEvents lifecycle
|
||||
// after the initial authenticated service event has been sent. When nil, the
|
||||
// gateway keeps authenticated SubscribeEvents streams open until the client
|
||||
// cancels them, the server shuts down, or a later stream send fails.
|
||||
Service gatewayv1.EdgeGatewayServer
|
||||
|
||||
// Router resolves the exact downstream unary client for the verified
|
||||
// message_type value. When nil, the authenticated unary surface uses an
|
||||
// empty exact-match router and returns UNIMPLEMENTED for unrouted commands.
|
||||
Router downstream.Router
|
||||
|
||||
// ResponseSigner signs authenticated unary responses after downstream
|
||||
// execution succeeds. When nil, the unary surface fails closed once it needs
|
||||
// to sign a routed response.
|
||||
ResponseSigner authn.ResponseSigner
|
||||
|
||||
// SessionCache resolves authenticated device sessions after the envelope
|
||||
// gate succeeds. When nil, the authenticated gRPC surface remains runnable
|
||||
// but valid envelopes fail closed as session-cache unavailable.
|
||||
SessionCache session.Cache
|
||||
|
||||
// Clock provides current server time for freshness checks. When nil, the
|
||||
// authenticated gRPC surface uses the system clock.
|
||||
Clock clock.Clock
|
||||
|
||||
// ReplayStore reserves authenticated request identifiers after signature
|
||||
// verification. When nil, valid requests fail closed as replay-store
|
||||
// unavailable.
|
||||
ReplayStore replay.Store
|
||||
|
||||
// Limiter applies authenticated rate limits after the request passes the
|
||||
// transport authenticity checks. When nil, the authenticated gRPC surface
|
||||
// uses a process-local in-memory limiter.
|
||||
Limiter AuthenticatedRequestLimiter
|
||||
|
||||
// Policy evaluates later authenticated edge policy after rate limits pass.
|
||||
// When nil, the authenticated gRPC surface applies a no-op allow policy.
|
||||
Policy AuthenticatedRequestPolicy
|
||||
|
||||
// Logger writes structured logs for authenticated gRPC traffic.
|
||||
Logger *zap.Logger
|
||||
|
||||
// Telemetry records low-cardinality gRPC metrics.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
// PushHub is the active authenticated push-stream hub. When present, the
|
||||
// server closes active streams before GracefulStop during shutdown.
|
||||
PushHub *push.Hub
|
||||
}
|
||||
|
||||
// Server owns the authenticated gRPC listener exposed by the gateway.
|
||||
type Server struct {
|
||||
cfg config.AuthenticatedGRPCConfig
|
||||
service gatewayv1.EdgeGatewayServer
|
||||
logger *zap.Logger
|
||||
pushHub *push.Hub
|
||||
metrics *telemetry.Runtime
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *grpc.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs an authenticated gRPC server for the supplied listener
|
||||
// configuration and dependency bundle. Nil dependencies are replaced with safe
|
||||
// defaults so the gateway can expose the documented transport surface with the
|
||||
// full auth pipeline wired from built-in fallbacks.
|
||||
func NewServer(cfg config.AuthenticatedGRPCConfig, deps ServerDependencies) *Server {
|
||||
deps = normalizeServerDependencies(deps)
|
||||
|
||||
finalService := newCommandRoutingService(
|
||||
newAuthenticatedPushStreamService(deps.Service, deps.ResponseSigner, deps.Clock),
|
||||
deps.Router,
|
||||
deps.ResponseSigner,
|
||||
deps.Clock,
|
||||
cfg.DownstreamTimeout,
|
||||
)
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
service: newEnvelopeValidatingService(
|
||||
newSessionLookupService(
|
||||
newPayloadHashVerifyingService(
|
||||
newSignatureVerifyingService(
|
||||
newFreshnessAndReplayService(
|
||||
newAuthenticatedRateLimitService(
|
||||
finalService,
|
||||
deps.Limiter,
|
||||
deps.Policy,
|
||||
cfg.AntiAbuse,
|
||||
),
|
||||
deps.Clock,
|
||||
deps.ReplayStore,
|
||||
cfg.FreshnessWindow,
|
||||
),
|
||||
),
|
||||
),
|
||||
deps.SessionCache,
|
||||
),
|
||||
),
|
||||
logger: deps.Logger.Named("authenticated_grpc"),
|
||||
pushHub: deps.PushHub,
|
||||
metrics: deps.Telemetry,
|
||||
}
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the authenticated gRPC surface
|
||||
// until Shutdown closes the server.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run authenticated gRPC server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", s.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run authenticated gRPC server: listen on %q: %w", s.cfg.Addr, err)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer(
|
||||
grpc.ConnectionTimeout(s.cfg.ConnectionTimeout),
|
||||
grpc.StatsHandler(otelgrpc.NewServerHandler()),
|
||||
grpc.ChainUnaryInterceptor(observabilityUnaryInterceptor(s.logger, s.metrics)),
|
||||
grpc.ChainStreamInterceptor(observabilityStreamInterceptor(s.logger, s.metrics)),
|
||||
)
|
||||
gatewayv1.RegisterEdgeGatewayServer(grpcServer, s.service)
|
||||
|
||||
s.stateMu.Lock()
|
||||
s.server = grpcServer
|
||||
s.listener = listener
|
||||
s.stateMu.Unlock()
|
||||
|
||||
s.logger.Info("authenticated gRPC server started", zap.String("addr", listener.Addr().String()))
|
||||
|
||||
defer func() {
|
||||
s.stateMu.Lock()
|
||||
s.server = nil
|
||||
s.listener = nil
|
||||
s.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = grpcServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, grpc.ErrServerStopped):
|
||||
s.logger.Info("authenticated gRPC server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run authenticated gRPC server: serve on %q: %w", s.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the authenticated gRPC server within ctx. When the
|
||||
// graceful stop exceeds ctx, the server is force-stopped before returning the
|
||||
// timeout to the caller.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown authenticated gRPC server: nil context")
|
||||
}
|
||||
|
||||
s.stateMu.RLock()
|
||||
server := s.server
|
||||
s.stateMu.RUnlock()
|
||||
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.pushHub != nil {
|
||||
s.pushHub.Shutdown()
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
server.GracefulStop()
|
||||
close(stopped)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stopped:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
server.Stop()
|
||||
<-stopped
|
||||
return fmt.Errorf("shutdown authenticated gRPC server: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) listenAddr() string {
|
||||
s.stateMu.RLock()
|
||||
defer s.stateMu.RUnlock()
|
||||
|
||||
if s.listener == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.listener.Addr().String()
|
||||
}
|
||||
|
||||
func normalizeServerDependencies(deps ServerDependencies) ServerDependencies {
|
||||
if deps.Router == nil {
|
||||
deps.Router = downstream.NewStaticRouter(nil)
|
||||
}
|
||||
if deps.ResponseSigner == nil {
|
||||
deps.ResponseSigner = unavailableResponseSigner{}
|
||||
}
|
||||
if deps.SessionCache == nil {
|
||||
deps.SessionCache = unavailableSessionCache{}
|
||||
}
|
||||
if deps.Clock == nil {
|
||||
deps.Clock = clock.System{}
|
||||
}
|
||||
if deps.ReplayStore == nil {
|
||||
deps.ReplayStore = unavailableReplayStore{}
|
||||
}
|
||||
if deps.Limiter == nil {
|
||||
deps.Limiter = ratelimit.NewInMemory()
|
||||
}
|
||||
if deps.Policy == nil {
|
||||
deps.Policy = noopAuthenticatedRequestPolicy{}
|
||||
}
|
||||
if deps.Logger == nil {
|
||||
deps.Logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return deps
|
||||
}
|
||||
Reference in New Issue
Block a user