diff --git a/sei-cosmos/server/config/config.go b/sei-cosmos/server/config/config.go index 1724161612..dc83bc5f9e 100644 --- a/sei-cosmos/server/config/config.go +++ b/sei-cosmos/server/config/config.go @@ -26,6 +26,10 @@ const ( // DefaultGRPCWebAddress defines the default address to bind the gRPC-web server to. DefaultGRPCWebAddress = "0.0.0.0:9091" + // DefaultGRPCWebMaxOpenConnections defines the default maximum number of + // simultaneous open connections for the gRPC-web server. + DefaultGRPCWebMaxOpenConnections = 1000 + // DefaultOccEanbled defines whether to use OCC for tx processing DefaultOccEnabled = true ) @@ -180,6 +184,9 @@ type GRPCWebConfig struct { // EnableUnsafeCORS defines if CORS should be enabled (unsafe - use it at your own risk) EnableUnsafeCORS bool `mapstructure:"enable-unsafe-cors"` + + // MaxOpenConnections defines the maximum number of simultaneous open connections. 0 means unlimited. + MaxOpenConnections uint `mapstructure:"max-open-connections"` } // StateSyncConfig defines the state sync snapshot configuration. @@ -292,8 +299,9 @@ func DefaultConfig() *Config { Offline: false, }, GRPCWeb: GRPCWebConfig{ - Enable: true, - Address: DefaultGRPCWebAddress, + Enable: true, + Address: DefaultGRPCWebAddress, + MaxOpenConnections: DefaultGRPCWebMaxOpenConnections, }, StateSync: StateSyncConfig{ SnapshotInterval: 0, @@ -357,6 +365,14 @@ func GetConfig(v *viper.Viper) (Config, error) { flatKVConfig.EnableReadWriteMetrics = v.GetBool("state-commit.flatkv.enable-read-write-metrics") } + // Apply the in-code default when the key is absent so that nodes upgrading + // with an older app.toml (which lacks this key) are still bounded rather + // than running with unlimited connections. + grpcWebMaxOpenConnections := uint(DefaultGRPCWebMaxOpenConnections) + if v.IsSet("grpc-web.max-open-connections") { + grpcWebMaxOpenConnections = v.GetUint("grpc-web.max-open-connections") + } + return Config{ BaseConfig: BaseConfig{ MinGasPrices: v.GetString("minimum-gas-prices"), @@ -404,9 +420,10 @@ func GetConfig(v *viper.Viper) (Config, error) { Address: v.GetString("grpc.address"), }, GRPCWeb: GRPCWebConfig{ - Enable: v.GetBool("grpc-web.enable"), - Address: v.GetString("grpc-web.address"), - EnableUnsafeCORS: v.GetBool("grpc-web.enable-unsafe-cors"), + Enable: v.GetBool("grpc-web.enable"), + Address: v.GetString("grpc-web.address"), + EnableUnsafeCORS: v.GetBool("grpc-web.enable-unsafe-cors"), + MaxOpenConnections: grpcWebMaxOpenConnections, }, StateSync: StateSyncConfig{ SnapshotInterval: v.GetUint64("state-sync.snapshot-interval"), diff --git a/sei-cosmos/server/config/config_test.go b/sei-cosmos/server/config/config_test.go index 26b984573f..0961681e81 100644 --- a/sei-cosmos/server/config/config_test.go +++ b/sei-cosmos/server/config/config_test.go @@ -74,6 +74,7 @@ func TestDefaultGRPCWebConfig(t *testing.T) { cfg := DefaultConfig() require.True(t, cfg.GRPCWeb.Enable) require.Equal(t, DefaultGRPCWebAddress, cfg.GRPCWeb.Address) + require.Equal(t, uint(DefaultGRPCWebMaxOpenConnections), cfg.GRPCWeb.MaxOpenConnections) } func TestDefaultRosettaConfig(t *testing.T) { @@ -283,6 +284,40 @@ func TestSetAndGetMinGasPrices(t *testing.T) { require.Equal(t, "uatom", parsed[1].Denom) } +func TestGetConfigGRPCWebMaxOpenConnections(t *testing.T) { + baseViper := func() *viper.Viper { + v := viper.New() + v.Set("minimum-gas-prices", DefaultMinGasPrices) + v.Set("telemetry.global-labels", []interface{}{}) + return v + } + + t.Run("missing key falls back to the in-code default", func(t *testing.T) { + // Mirrors a node upgrading with an older app.toml that predates the + // grpc-web.max-open-connections key + cfg, err := GetConfig(baseViper()) + require.NoError(t, err) + require.Equal(t, uint(DefaultGRPCWebMaxOpenConnections), cfg.GRPCWeb.MaxOpenConnections) + }) + + t.Run("explicit zero is preserved as unlimited", func(t *testing.T) { + v := baseViper() + v.Set("grpc-web.max-open-connections", 0) + cfg, err := GetConfig(v) + require.NoError(t, err) + require.Equal(t, uint(0), cfg.GRPCWeb.MaxOpenConnections, + "explicit 0 must remain an opt-in to unlimited connections") + }) + + t.Run("explicit value overrides the default", func(t *testing.T) { + v := baseViper() + v.Set("grpc-web.max-open-connections", 250) + cfg, err := GetConfig(v) + require.NoError(t, err) + require.Equal(t, uint(250), cfg.GRPCWeb.MaxOpenConnections) + }) +} + func TestGetConfigStateCommit(t *testing.T) { v := viper.New() diff --git a/sei-cosmos/server/config/toml.go b/sei-cosmos/server/config/toml.go index ac931084be..77fa762542 100644 --- a/sei-cosmos/server/config/toml.go +++ b/sei-cosmos/server/config/toml.go @@ -205,6 +205,9 @@ address = "{{ .GRPCWeb.Address }}" # EnableUnsafeCORS defines if CORS should be enabled (unsafe - use it at your own risk). enable-unsafe-cors = {{ .GRPCWeb.EnableUnsafeCORS }} +# MaxOpenConnections defines the number of maximum open connections. 0 means unlimited. +max-open-connections = {{ .GRPCWeb.MaxOpenConnections }} + ############################################################################### ### Genesis Configuration (Auto-managed) ### ############################################################################### diff --git a/sei-cosmos/server/grpc/grpc_web.go b/sei-cosmos/server/grpc/grpc_web.go index ab951bb528..e80e042751 100644 --- a/sei-cosmos/server/grpc/grpc_web.go +++ b/sei-cosmos/server/grpc/grpc_web.go @@ -2,10 +2,13 @@ package grpc import ( "fmt" + "math" + "net" "net/http" "time" "github.com/improbable-eng/grpc-web/go/grpcweb" + "golang.org/x/net/netutil" "google.golang.org/grpc" "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" @@ -25,20 +28,34 @@ func StartGRPCWeb(grpcSrv *grpc.Server, config config.Config) (*http.Server, err wrappedServer := grpcweb.WrapServer(grpcSrv, options...) grpcWebSrv := &http.Server{ - Addr: config.GRPCWeb.Address, Handler: wrappedServer, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 2 * time.Minute, + IdleTimeout: 30 * time.Second, } - errCh := make(chan error) + listener, err := net.Listen("tcp", config.GRPCWeb.Address) + if err != nil { + return nil, fmt.Errorf("[grpc-web] failed to listen on %s: %w", config.GRPCWeb.Address, err) + } + if config.GRPCWeb.MaxOpenConnections > 0 { + maxConn := config.GRPCWeb.MaxOpenConnections + if maxConn > math.MaxInt { + maxConn = math.MaxInt + } + listener = netutil.LimitListener(listener, int(maxConn)) //nolint:gosec // G115: clamped to math.MaxInt above + } + + errCh := make(chan error, 1) go func() { - if err := grpcWebSrv.ListenAndServe(); err != nil { - errCh <- fmt.Errorf("[grpc] failed to serve: %w", err) + if err := grpcWebSrv.Serve(listener); err != nil { + errCh <- fmt.Errorf("[grpc-web] failed to serve: %w", err) } }() select { - case err := <-errCh: + case err = <-errCh: return nil, err case <-time.After(types.ServerStartTime): // assume server started successfully return grpcWebSrv, nil diff --git a/sei-cosmos/server/grpc/grpc_web_timeout_test.go b/sei-cosmos/server/grpc/grpc_web_timeout_test.go new file mode 100644 index 0000000000..7b2e3437a0 --- /dev/null +++ b/sei-cosmos/server/grpc/grpc_web_timeout_test.go @@ -0,0 +1,44 @@ +package grpc_test + +import ( + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" + srvgrpc "github.com/sei-protocol/sei-chain/sei-cosmos/server/grpc" +) + +// TestStartGRPCWebTimeouts verifies that StartGRPCWeb sets all HTTP timeout +// fields needed to prevent body-stall DoS attacks. +func TestStartGRPCWebTimeouts(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + err = ln.Close() + require.NoError(t, err) + + grpcSrv := grpc.NewServer() + cfg := config.Config{ + GRPCWeb: config.GRPCWebConfig{ + Enable: true, + Address: addr, + }, + } + + srv, err := srvgrpc.StartGRPCWeb(grpcSrv, cfg) + require.NoError(t, err) + require.NotNil(t, srv) + defer func() { + err = srv.Shutdown(t.Context()) //nolint:errcheck + require.NoError(t, err) + }() + + require.Equal(t, 10*time.Second, srv.ReadHeaderTimeout, "ReadHeaderTimeout") + require.Equal(t, 30*time.Second, srv.ReadTimeout, "ReadTimeout") + require.Equal(t, 2*time.Minute, srv.WriteTimeout, "WriteTimeout") + require.Equal(t, 30*time.Second, srv.IdleTimeout, "IdleTimeout") +}