Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
* [#4009](https://github.com/sei-protocol/sei-chain/pull/4009) Bound `/store/*/subspace` ABCI queries with pair/byte caps, empty-prefix rejection, SS-path concurrency limits, and context-aware iteration to prevent memory-exhaustion DoS.
* [#4032](https://github.com/sei-protocol/sei-chain/pull/4032) fix(config): the default `telemetry.prometheus-retention-time` drops from `7200` to `0`, so neither app.toml-generation pipeline (`seid init`, or the file a node writes for itself on any other subcommand) starts the Prometheus metrics sink unless an operator sets a positive retention. Freshly generated nodes keep the bounded in-memory telemetry sink used by SIGUSR1 dumps. Existing `app.toml` files are unchanged.
* [#4021](https://github.com/sei-protocol/sei-chain/pull/4021) feat(grpc): per-IP rate-limit admission for the gRPC plane, off by default behind `[grpc] rate-limiting-enabled` (new `ip-rate-limit-rps` / `ip-rate-limit-burst` / `trusted-proxy-cidrs`, defaults 10 rps / 20 burst / trust no proxy). Native gRPC (:9090) is admitted by a tap handler and gRPC-Web (:9091) by HTTP middleware, both before the request is protobuf-decoded, so a throttled caller cannot spend the decoder; streams pay one token to establish and one per inbound message. Both planes draw from the same per-IP buckets. Over-budget callers get `ResourceExhausted` on :9090 and HTTP 429 on :9091, counted by `rpc_rate_limit_rejected_total{plane="grpc", method_namespace}`.
* [#4078](https://github.com/sei-protocol/sei-chain/pull/4078) feat(grpc): bound concurrent in-flight RPCs and open connections per IP on the gRPC query plane. New `[grpc] max-connections-per-ip` and `[grpc-web] max-connections-per-ip` (default 0, unlimited) optionally cap one address's share of the global connection budget on :9090 and :9091, regardless of `rate-limiting-enabled`. New `[grpc] max-in-flight-per-ip` (default 100) caps concurrent RPCs per address when `rate-limiting-enabled = true`: the slot is taken at the HTTP/2 HEADERS frame and returned when the RPC ends. Both planes draw from the same per-IP pool. Concurrency rejections return `ResourceExhausted` on :9090 and HTTP 429 on :9091, counted by `rpc_inflight_rejected_total{plane, method_namespace}`; refused connections are counted by `rpc_connection_rejected_total{plane}`.

### Upgrade guide
* **IBC core removal.** Removes the retired IBC core source, protobufs, light clients, CLI, and simulation support. Retired IBC stores remain mounted but are omitted from `export-genesis`; preserve the state database or use v6.6 freeze nodes for historical IBC data.
Expand All @@ -48,6 +49,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
* [#4032](https://github.com/sei-protocol/sei-chain/pull/4032) **The Prometheus telemetry sink is off by default.** A node whose `app.toml` is generated by this release gets `prometheus-retention-time = 0`, which leaves the sink uncreated even though `telemetry.enabled` stays `true`. `GET /metrics?format=prometheus` on the app API server (:1317) then returns `prometheus metrics are not enabled`, and the `seid` process exports no application Prometheus series. **Operators who scrape application metrics should set a positive `[telemetry] prometheus-retention-time` in `app.toml` (the previous default was `7200`) before generating a new configuration file.** Nodes that already have `prometheus-retention-time` written in `app.toml` are unaffected.
* [#3984](https://github.com/sei-protocol/sei-chain/pull/3984) **ABCI/gRPC pagination is now capped by default.** Untrusted callers requesting `limit` above 1000, `offset` above 10000, or a scan that exceeds 11000 total iterations now get `InvalidArgument` (over-cap) or a partial page with `next_key` (budget exhausted) instead of the previously unbounded scan. Clients that page with large limits/offsets, or trusted internal indexers, should either follow `next_key` for resumption or be added to the new `[query] trusted-cidrs` allowlist (or set `[query] disable-limits = true`) before upgrading.
* [#4021](https://github.com/sei-protocol/sei-chain/pull/4021) **gRPC per-IP rate limiting defaults are deliberately conservative.** Admission stays off unless `[grpc] rate-limiting-enabled = true`, but the defaults it enables are 10 rps / 20 burst per IP. Streams pay one token to establish and one per inbound message, so at the default burst a client that sends more than ~20 messages in a burst is cut off mid-stream with `ResourceExhausted`. Operators enabling admission should size `ip-rate-limit-rps` / `ip-rate-limit-burst` against their heaviest streaming client, and set `trusted-proxy-cidrs` to their ingress CIDRs so callers are not all bucketed under the proxy's IP.
* [#4078](https://github.com/sei-protocol/sei-chain/pull/4078) **Optional per-IP connection and in-flight RPC caps on :9090 and :9091.** `max-connections-per-ip` defaults to 0 (unlimited) on both planes; set a positive value to cap one address's share of the global connection budget. The cap keys on the TCP peer that opened the socket and is unaffected by `trusted-proxy-cidrs`, which needs a request to read `X-Forwarded-For` from and so cannot apply at accept time: everything reaching the node over `127.0.0.1` shares one allowance, as does every client behind a proxy or load balancer, and IPv6 peers share per /64. **Operators exposing public gRPC query endpoints directly may want to set `[grpc] max-connections-per-ip` and `[grpc-web] max-connections-per-ip` (for example to 100, a tenth of the default global budget); on a node behind an ingress, size it against that ingress's total concurrent connections rather than one client's, or leave it at 0.** The companion `[grpc] max-in-flight-per-ip` (default 100 concurrent RPCs per address) only takes effect when `rate-limiting-enabled = true`; size it against your heaviest client's concurrency.
* [#4009](https://github.com/sei-protocol/sei-chain/pull/4009) **`/store/*/subspace` scans are now capped.** Wide prefix scans that previously returned unbounded KV pairs now fail with `subspace result exceeds limit` once they would exceed the default caps of 1,000 pairs or 4 MiB of accumulated key+value bytes. Empty prefixes are rejected. Indexers and tooling that issue wide `/subspace` queries must narrow their prefixes, shard by sub-prefix, or raise `[state-commit] sc-subspace-max-pairs` and `sc-subspace-max-bytes` before upgrading. Values `<= 0` resolve to these defaults; there is no unlimited setting.
* [#3927](https://github.com/sei-protocol/sei-chain/pull/3927) **Legacy Sei JSON-RPC and CLI removal.** Removes `sei_associate`, `sei_getBlockByHash`, `sei_getBlockByHashExcludeTraceFail`, `sei_getBlockTransactionCountByHash`, `sei_getBlockTransactionCountByNumber`, `sei_getEvmTx`, `sei_getFilterChanges`, `sei_getFilterLogs`, `sei_getLogs`, `sei_getTransactionByBlockHashAndIndex`, `sei_getTransactionByBlockNumberAndIndex`, `sei_getTransactionByHash`, `sei_getTransactionCount`, `sei_getTransactionErrorByHash`, `sei_getTransactionReceiptExcludeTraceFail`, `sei_getVMError`, `sei_newBlockFilter`, `sei_newFilter`, `sei_sign`, and `sei_uninstallFilter`. Use standard `eth_*` methods for EVM-originated data and `seid tx evm native-associate <custom-message> -y` for address association. There is no block- or filter-level replacement for discovering Cosmos-originated synthetic logs; clients that know the synthetic transaction hash can enable `sei_getTransactionReceipt`.

Expand Down
2 changes: 2 additions & 0 deletions config/cosmosbase/agreement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func readerValues(t *testing.T) map[string]string {
"grpc.address": fmt.Sprint(cfg.GRPC.Address),
"grpc.max-recv-msg-size": fmt.Sprint(cfg.GRPC.MaxRecvMsgSize),
"grpc.max-open-connections": fmt.Sprint(cfg.GRPC.MaxOpenConnections),
"grpc.max-connections-per-ip": fmt.Sprint(cfg.GRPC.MaxConnectionsPerIP),
"grpc.max-connection-idle": fmt.Sprint(cfg.GRPC.MaxConnectionIdle),
"grpc.max-connection-age": fmt.Sprint(cfg.GRPC.MaxConnectionAge),
"grpc.max-connection-age-grace": fmt.Sprint(cfg.GRPC.MaxConnectionAgeGrace),
Expand All @@ -111,6 +112,7 @@ func readerValues(t *testing.T) map[string]string {
"grpc.keepalive-permit-without-stream": fmt.Sprint(cfg.GRPC.KeepalivePermitWithoutStream),
"grpc.ip-rate-limit-rps": fmt.Sprint(cfg.GRPC.IPRateLimitRPS),
"grpc.ip-rate-limit-burst": fmt.Sprint(cfg.GRPC.IPRateLimitBurst),
"grpc.max-in-flight-per-ip": fmt.Sprint(cfg.GRPC.MaxInFlightPerIP),
"grpc.rate-limiting-enabled": fmt.Sprint(cfg.GRPC.RateLimitingEnabled),
"grpc.trusted-proxy-cidrs": fmt.Sprint(cfg.GRPC.TrustedProxyCIDRs),
"telemetry.service-name": fmt.Sprint(cfg.Telemetry.ServiceName),
Expand Down
9 changes: 5 additions & 4 deletions config/cosmosbase/cosmosbase.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,11 @@ func apiDefaults(mode registry.Mode) any { return forMode(mode).API }
// interface follows and for the same reason. The upstream default is on for every kind, so declaring that
// would state an open interface on the nodes meant to expose the least.
//
// Nine of these fifteen keys are read only when the key is present. Two more are durations read through a
// clamp that rescues a negative value and does nothing for an absent one, so those two are unguarded and
// their clobber leaves no trace. The durations are declared as durations and written into a file as text,
// which is the shape the reader parses back.
// Most gRPC settings preserve their in-code defaults when the key is absent because the reader only reads
// them after an IsSet check. The connection-age durations are the exception: they are read unconditionally

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This rewrite makes a claim the code does not support. Four grpc keys are read with no IsSet guard and no clamp: grpc.enable, grpc.address, grpc.keepalive-permit-without-stream, and grpc.rate-limiting-enabled (sei-cosmos/server/config/config.go:731, :732, :742, :746). The connection-age durations are not "the exception" — they are the exception among the unguarded keys in that their absent-key value coincides with the declared default.

That matters most for grpc.enable, which is the key the paragraph directly above this one is about: it is declared true for a full node and an archive node and false for a validator and a seed, and because it is read unconditionally an absent key casts to false and clobbers the declared true. A reader who takes this paragraph at face value concludes the mode rule survives an absent key, which is the opposite of what happens. The text this replaced accounted for those four by arithmetic ("Nine of these fifteen keys... Two more are durations..."), so the gap was at least visible; the new phrasing closes it with a false generalization.

The two keys this PR adds are both IsSet-guarded, so the honest update is to keep the shape of the old claim rather than broaden it — name the unguarded set, and say which members of it are rescued by a coinciding zero and which are not.

// and clamped only when negative. Their default is zero, and Viper also returns zero for an absent duration,
// so an omitted key resolves to the same value as the declared default. Duration defaults are rendered as
// text in the config file, matching the shape the reader parses back.
func grpcDefaults(mode registry.Mode) any { return forMode(mode).GRPC }

// stateSyncDefaults is what the snapshot settings resolve to for a node of this kind.
Expand Down
5 changes: 3 additions & 2 deletions config/cosmosbase/cosmosbase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,12 @@ func TestTheRESTKeysAreTheOnesItsReaderResolves(t *testing.T) {
func TestTheGRPCKeysAreTheOnesItsReaderResolves(t *testing.T) {
requireDeclares(t, GRPCSectionName, []string{
"grpc.enable", "grpc.address", "grpc.max-recv-msg-size", "grpc.max-open-connections",
"grpc.max-connections-per-ip",
"grpc.max-connection-idle", "grpc.max-connection-age", "grpc.max-connection-age-grace",
"grpc.keepalive-time", "grpc.keepalive-timeout", "grpc.keepalive-min-time",
"grpc.keepalive-permit-without-stream",
"grpc.ip-rate-limit-rps", "grpc.ip-rate-limit-burst", "grpc.rate-limiting-enabled",
"grpc.trusted-proxy-cidrs",
"grpc.ip-rate-limit-rps", "grpc.ip-rate-limit-burst", "grpc.max-in-flight-per-ip",
"grpc.rate-limiting-enabled", "grpc.trusted-proxy-cidrs",
})
}

Expand Down
88 changes: 88 additions & 0 deletions ratelimiter/conn_limit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package ratelimiter

import (
"context"
"net"
"sync"

"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)

// ConnLimitListener returns a listener that bounds the number of simultaneously
// open connections from any one client address to maxPerIP. A non-positive
// maxPerIP returns inner unchanged.
//
// It bounds the layer below the RPC — accepted sockets, TLS handshakes, HTTP/2
// frame state — which a per-RPC counter cannot see, and it caps the per-address
// share of the global connection budget. Wrap the raw listener with this before
// the global cap, so a connection this rejects never spends a global slot.
//
// Addresses are keyed on the TCP peer, normalized the way rate-limit buckets
// are: a client rotating within an IPv6 /64 does not get a fresh allowance per
// address, and every client sharing an address shares one allowance.
// trusted-proxy-cidrs does not apply here, so on a node behind a proxy or load
// balancer maxPerIP bounds that proxy as a whole rather than its clients.
func ConnLimitListener(inner net.Listener, plane string, maxPerIP int) net.Listener {
if maxPerIP <= 0 {
return inner
}
return &connLimitListener{
Listener: inner,
plane: plane,
counter: newInflightCounter(maxPerIP),
}
}

// connLimitListener is the listener ConnLimitListener returns.
type connLimitListener struct {
net.Listener

plane string
counter *inflightCounter
}

// Accept returns the next connection whose client address is under the cap,
// closing and counting the ones that are not.
//
// Over-cap connections are dropped here rather than surfaced as an Accept error,
// because an Accept error stops the serving loop and would turn one client's
// excess into an outage for every other client.
func (l *connLimitListener) Accept() (net.Conn, error) {
for {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
// The peer address is all a listener has: nothing has been read off the
// socket yet, so there is no X-Forwarded-For to resolve a client behind
// a trusted proxy to.
key := bucketKey(stripPort(conn.RemoteAddr().String()))
if l.counter.acquire(key) {
return &limitedConn{Conn: conn, release: func() { l.counter.release(key) }}, nil
}
registryMetrics.connRejectedCounter.Add(
context.Background(),
1,
metric.WithAttributes(attribute.String("plane", l.plane)),
)
_ = conn.Close()
}
}

// limitedConn returns its address's connection slot when it is closed.
type limitedConn struct {
net.Conn

once sync.Once
release func()
}

// Close closes the underlying connection and returns its slot. It is safe to
// call more than once: net/http and grpc-go both close a connection from more
// than one path, and a double release would hand the address a free slot.
func (c *limitedConn) Close() error {
err := c.Conn.Close()
c.once.Do(c.release)
return err
}
118 changes: 118 additions & 0 deletions ratelimiter/conn_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package ratelimiter

import (
"io"
"net"
"testing"
"time"

"github.com/stretchr/testify/require"
)

// acceptedConns runs a listener's Accept loop into a channel so a test can see
// which connections the per-IP cap let through.
func acceptedConns(t *testing.T, ln net.Listener) <-chan net.Conn {
t.Helper()
out := make(chan net.Conn, 16)
go func() {
defer close(out)
for {
conn, err := ln.Accept()
if err != nil {
return
}
out <- conn
}
}()
return out
}

func dialTo(t *testing.T, ln net.Listener) net.Conn {
t.Helper()
conn, err := net.Dial("tcp", ln.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
return conn
}

// requireClosedByServer pins that the server hung up on conn rather than serving
// it, which is how an over-cap connection is refused.
func requireClosedByServer(t *testing.T, conn net.Conn) {
t.Helper()
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
_, err := conn.Read(make([]byte, 1))
require.ErrorIs(t, err, io.EOF)
}

func TestConnLimitListenerCapsConnectionsPerIP(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
ln := ConnLimitListener(raw, PlaneGRPC, 2)
t.Cleanup(func() { _ = ln.Close() })
accepted := acceptedConns(t, ln)

first := dialTo(t, ln)
second := dialTo(t, ln)
served := []net.Conn{<-accepted, <-accepted}

// The third is dropped, and the loop keeps serving rather than failing.
requireClosedByServer(t, dialTo(t, ln))
require.Empty(t, accepted)

// Closing a served connection returns its slot.
require.NoError(t, served[0].Close())
require.NoError(t, first.Close())
dialTo(t, ln)
require.NotNil(t, <-accepted)

_ = second.Close()
_ = served[1].Close()
}

// TestConnLimitListenerDoubleCloseReturnsOneSlot pins the idempotent release:
// net/http and grpc-go both close a connection from more than one path, and a
// second release would hand the address a slot it is not holding.
func TestConnLimitListenerDoubleCloseReturnsOneSlot(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
ln := ConnLimitListener(raw, PlaneGRPC, 1)
t.Cleanup(func() { _ = ln.Close() })
accepted := acceptedConns(t, ln)

dialTo(t, ln)
served := <-accepted
require.NoError(t, served.Close())
_ = served.Close()

limiter, ok := ln.(*connLimitListener)
require.True(t, ok)
require.Equal(t, 0, limiter.counter.heldFor("127.0.0.1"))

// One slot came back, not two.
dialTo(t, ln)
require.NotNil(t, <-accepted)
requireClosedByServer(t, dialTo(t, ln))
}

// TestConnLimitListenerDisabledReturnsInner pins that a non-positive cap adds no
// wrapper at all, so the listener behaves exactly as before.
func TestConnLimitListenerDisabledReturnsInner(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = raw.Close() })

require.Same(t, raw, ConnLimitListener(raw, PlaneGRPC, 0))
require.Same(t, raw, ConnLimitListener(raw, PlaneGRPC, -1))
}

// TestConnLimitListenerAcceptStopsOnListenerError pins that a closed listener
// still ends the Accept loop rather than spinning inside it.
func TestConnLimitListenerAcceptStopsOnListenerError(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
ln := ConnLimitListener(raw, PlaneGRPC, 1)
require.NoError(t, ln.Close())

_, err = ln.Accept()
require.Error(t, err)
}
Loading
Loading