diff --git a/auth/authorizer.go b/auth/authorizer.go index b37713278..19b29d50f 100644 --- a/auth/authorizer.go +++ b/auth/authorizer.go @@ -63,6 +63,11 @@ func NewAuthorizer(appCtx context.Context, logger *zerolog.Logger, projectId str if err != nil { return nil, err } + case common.AuthTypeForwardedClientId: + if cfg.ForwardedClientId == nil { + return nil, common.NewErrInvalidConfig("forwardedClientId strategy config is nil") + } + strategy = NewForwardedClientIdStrategy(cfg.ForwardedClientId) default: return nil, common.NewErrInvalidConfig(fmt.Sprintf("unknown auth strategy type: %s", cfg.Type)) } diff --git a/auth/http.go b/auth/http.go index 3b6698662..2ace053e6 100644 --- a/auth/http.go +++ b/auth/http.go @@ -5,12 +5,13 @@ import ( "errors" "net/http" "net/url" + "path" "strings" "github.com/erpc/erpc/common" ) -func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, args url.Values) (*AuthPayload, error) { +func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, args url.Values, requestPath string) (*AuthPayload, error) { ap := &AuthPayload{ Method: method, } @@ -25,11 +26,22 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a ap.Secret = &SecretPayload{ Value: secret, } + } else if apikey := args.Get("apikey"); apikey != "" { + // Alias used by edge gateways / clients that speak "apikey" rather than "secret". + ap.Type = common.AuthTypeSecret + ap.Secret = &SecretPayload{ + Value: apikey, + } } else if tkn := headers.Get("X-ERPC-Secret-Token"); tkn != "" { ap.Type = common.AuthTypeSecret ap.Secret = &SecretPayload{ Value: tkn, } + } else if apikey := firstNonEmptyHeader(headers, "apikey", "X-Api-Key"); apikey != "" { + ap.Type = common.AuthTypeSecret + ap.Secret = &SecretPayload{ + Value: apikey, + } } else if ath := headers.Get("Authorization"); ath != "" { ath = strings.TrimSpace(ath) parts := strings.SplitN(ath, " ", 2) @@ -77,6 +89,19 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a Message: normalizeSiweMessage(msg), } } + } else if pathSecret := singlePathSegmentSecret(requestPath); pathSecret != "" { + // Path form: https://host/ (with domain aliasing so the segment + // is not consumed as project/network). Avoids edge Lua/WASM filters. + ap.Type = common.AuthTypeSecret + ap.Secret = &SecretPayload{ + Value: pathSecret, + } + } else if clientId := firstNonEmptyHeader(headers, "X-Client-Id", "x-client-id"); clientId != "" { + // Gateway-injected identity after edge API-key auth (Envoy forwardClientIDHeader). + ap.Type = common.AuthTypeForwardedClientId + ap.ForwardedClientId = &ForwardedClientIdPayload{ + Value: clientId, + } } // Default to network strategy when no other auth signals are present. @@ -87,6 +112,37 @@ func NewPayloadFromHttp(method string, remoteAddr string, headers http.Header, a return ap, nil } +// singlePathSegmentSecret returns the sole path segment when the URL is +// `/` (or `//`). Multi-segment eRPC paths and reserved +// endpoints are ignored so routing/healthchecks stay unchanged. +func singlePathSegmentSecret(requestPath string) string { + if requestPath == "" { + return "" + } + ps := path.Clean(requestPath) + if ps == "/" || ps == "." { + return "" + } + seg := strings.TrimPrefix(ps, "/") + if seg == "" || strings.Contains(seg, "/") { + return "" + } + switch seg { + case "admin", "healthcheck", "metrics": + return "" + } + return seg +} + +func firstNonEmptyHeader(headers http.Header, names ...string) string { + for _, name := range names { + if v := strings.TrimSpace(headers.Get(name)); v != "" { + return v + } + } + return "" +} + func normalizeSiweMessage(msg string) string { decoded, err := base64.StdEncoding.DecodeString(msg) if err != nil { diff --git a/auth/payload.go b/auth/payload.go index 712003534..9971ba7c5 100644 --- a/auth/payload.go +++ b/auth/payload.go @@ -3,11 +3,18 @@ package auth import "github.com/erpc/erpc/common" type AuthPayload struct { - Method string - Type common.AuthType - Secret *SecretPayload - Jwt *JwtPayload - Siwe *SiwePayload + Method string + Type common.AuthType + Secret *SecretPayload + Jwt *JwtPayload + Siwe *SiwePayload + ForwardedClientId *ForwardedClientIdPayload +} + +// ForwardedClientIdPayload carries a gateway-injected client id (not a secret). +type ForwardedClientIdPayload struct { + Value string + RateLimitBudget string } // This payload is used by both "secret" and "database" strategies diff --git a/auth/strategy_forwarded_client_id.go b/auth/strategy_forwarded_client_id.go new file mode 100644 index 000000000..af14caeea --- /dev/null +++ b/auth/strategy_forwarded_client_id.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" + "strings" + + "github.com/erpc/erpc/common" +) + +// ForwardedClientIdStrategy authenticates using a non-secret client id +// header injected by a trusted gateway after API-key verification +// (e.g. Envoy SecurityPolicy apiKeyAuth.forwardClientIDHeader). +type ForwardedClientIdStrategy struct { + cfg *common.ForwardedClientIdStrategyConfig +} + +var _ AuthStrategy = &ForwardedClientIdStrategy{} + +func NewForwardedClientIdStrategy(cfg *common.ForwardedClientIdStrategyConfig) *ForwardedClientIdStrategy { + return &ForwardedClientIdStrategy{cfg: cfg} +} + +func (s *ForwardedClientIdStrategy) Supports(ap *AuthPayload) bool { + return ap != nil && ap.Type == common.AuthTypeForwardedClientId +} + +func (s *ForwardedClientIdStrategy) Authenticate(ctx context.Context, req *common.NormalizedRequest, ap *AuthPayload) (*common.User, error) { + if ap == nil || ap.ForwardedClientId == nil || strings.TrimSpace(ap.ForwardedClientId.Value) == "" { + return nil, common.NewErrAuthUnauthorized("forwardedClientId", "missing client id header") + } + + id := strings.TrimSpace(ap.ForwardedClientId.Value) + user := &common.User{Id: id} + if s.cfg != nil && s.cfg.RateLimitBudget != "" { + user.RateLimitBudget = s.cfg.RateLimitBudget + } else if ap.ForwardedClientId.RateLimitBudget != "" { + user.RateLimitBudget = ap.ForwardedClientId.RateLimitBudget + } + return user, nil +} diff --git a/auth/strategy_forwarded_client_id_test.go b/auth/strategy_forwarded_client_id_test.go new file mode 100644 index 000000000..a6f987bbc --- /dev/null +++ b/auth/strategy_forwarded_client_id_test.go @@ -0,0 +1,104 @@ +package auth + +import ( + "context" + "net/http" + "net/url" + "testing" + + "github.com/erpc/erpc/common" + "github.com/stretchr/testify/require" +) + +func TestNewPayloadFromHttp_ForwardedClientId(t *testing.T) { + headers := http.Header{} + headers.Set("X-Client-Id", "cl-no-alpha") + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", headers, url.Values{}, "/") + require.NoError(t, err) + require.Equal(t, common.AuthTypeForwardedClientId, ap.Type) + require.NotNil(t, ap.ForwardedClientId) + require.Equal(t, "cl-no-alpha", ap.ForwardedClientId.Value) +} + +func TestNewPayloadFromHttp_PathSecret(t *testing.T) { + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/my-secret-key") + require.NoError(t, err) + require.Equal(t, common.AuthTypeSecret, ap.Type) + require.NotNil(t, ap.Secret) + require.Equal(t, "my-secret-key", ap.Secret.Value) + + // Trailing slash is cleaned to a single segment. + ap, err = NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/my-secret-key/") + require.NoError(t, err) + require.Equal(t, common.AuthTypeSecret, ap.Type) + require.Equal(t, "my-secret-key", ap.Secret.Value) +} + +func TestNewPayloadFromHttp_PathSecretIgnoredForMultiSegment(t *testing.T) { + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, "/main/evm/1") + require.NoError(t, err) + require.Equal(t, common.AuthTypeNetwork, ap.Type) +} + +func TestNewPayloadFromHttp_PathSecretIgnoredForReserved(t *testing.T) { + for _, seg := range []string{"/admin", "/healthcheck", "/metrics"} { + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{}, seg) + require.NoError(t, err) + require.Equal(t, common.AuthTypeNetwork, ap.Type, "path %s", seg) + } +} + +func TestSecretStrategy_RejectsEmpty(t *testing.T) { + s := NewSecretStrategy(&common.SecretStrategyConfig{Id: "cl-no-01", Value: ""}) + _, err := s.Authenticate(context.Background(), nil, &AuthPayload{ + Type: common.AuthTypeSecret, + Secret: &SecretPayload{Value: ""}, + }) + require.Error(t, err) + + s = NewSecretStrategy(&common.SecretStrategyConfig{Id: "cl-no-01", Value: "real-secret"}) + user, err := s.Authenticate(context.Background(), nil, &AuthPayload{ + Type: common.AuthTypeSecret, + Secret: &SecretPayload{Value: "real-secret"}, + }) + require.NoError(t, err) + require.Equal(t, "cl-no-01", user.Id) +} + +func TestNewPayloadFromHttp_ApiKeyQueryAndHeader(t *testing.T) { + ap, err := NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", http.Header{}, url.Values{"apikey": []string{"q-key"}}, "/") + require.NoError(t, err) + require.Equal(t, common.AuthTypeSecret, ap.Type) + require.Equal(t, "q-key", ap.Secret.Value) + + headers := http.Header{} + headers.Set("apikey", "h-key") + ap, err = NewPayloadFromHttp("eth_blockNumber", "1.2.3.4:1234", headers, url.Values{}, "/") + require.NoError(t, err) + require.Equal(t, common.AuthTypeSecret, ap.Type) + require.Equal(t, "h-key", ap.Secret.Value) +} + +func TestForwardedClientIdStrategy_Authenticate(t *testing.T) { + s := NewForwardedClientIdStrategy(&common.ForwardedClientIdStrategyConfig{ + Header: "X-Client-Id", + RateLimitBudget: "default-budget", + }) + ap := &AuthPayload{ + Type: common.AuthTypeForwardedClientId, + ForwardedClientId: &ForwardedClientIdPayload{ + Value: "cl-no-beta", + }, + } + user, err := s.Authenticate(context.Background(), nil, ap) + require.NoError(t, err) + require.Equal(t, "cl-no-beta", user.Id) + require.Equal(t, "default-budget", user.RateLimitBudget) +} + +func TestForwardedClientIdStrategy_MissingHeader(t *testing.T) { + s := NewForwardedClientIdStrategy(&common.ForwardedClientIdStrategyConfig{}) + ap := &AuthPayload{Type: common.AuthTypeForwardedClientId} + _, err := s.Authenticate(context.Background(), nil, ap) + require.Error(t, err) +} diff --git a/auth/strategy_secret.go b/auth/strategy_secret.go index 271e18a26..51dc9026e 100644 --- a/auth/strategy_secret.go +++ b/auth/strategy_secret.go @@ -2,6 +2,7 @@ package auth import ( "context" + "strings" "github.com/erpc/erpc/common" ) @@ -21,6 +22,14 @@ func (s *SecretStrategy) Supports(ap *AuthPayload) bool { } func (s *SecretStrategy) Authenticate(ctx context.Context, req *common.NormalizedRequest, ap *AuthPayload) (*common.User, error) { + if ap == nil || ap.Secret == nil { + return nil, common.NewErrAuthUnauthorized("secret", "missing secret") + } + // Reject empty configured or presented secrets so a missing env expansion + // (value="") can never authenticate an empty path/query credential. + if strings.TrimSpace(s.cfg.Value) == "" || strings.TrimSpace(ap.Secret.Value) == "" { + return nil, common.NewErrAuthUnauthorized("secret", "invalid secret") + } if ap.Secret.Value != s.cfg.Value { return nil, common.NewErrAuthUnauthorized("secret", "invalid secret") } diff --git a/clients/registry.go b/clients/registry.go index b35e9a2d5..c512534c3 100644 --- a/clients/registry.go +++ b/clients/registry.go @@ -96,7 +96,9 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U var c ClientInterface var cerr error switch cfg.Type { - case common.UpstreamTypeEvm: + case common.UpstreamTypeEvm, common.UpstreamTypeJsonRpc: + // jsonrpc architecture reuses the generic HTTP/WS JSON-RPC clients + // (passthrough; no EVM chainId/state poller). gRPC BDS remains EVM-only. switch parsedUrl.Scheme { case "http", "https": c, cerr = NewGenericHttpJsonRpcClient( @@ -126,6 +128,10 @@ func (manager *ClientRegistry) CreateClient(appCtx context.Context, ups common.U cerr = fmt.Errorf("failed to create WebSocket client for upstream %v: %w", cfg.Id, cerr) } case "grpc", "grpc+bds": + if cfg.Type == common.UpstreamTypeJsonRpc { + cerr = fmt.Errorf("unsupported endpoint scheme: %v for upstream type jsonrpc: %v", parsedUrl.Scheme, cfg.Id) + break + } c, cerr = NewGrpcBdsClient( appCtx, &lg, diff --git a/common/architecture_jsonrpc.go b/common/architecture_jsonrpc.go new file mode 100644 index 000000000..edacd589a --- /dev/null +++ b/common/architecture_jsonrpc.go @@ -0,0 +1,12 @@ +package common + +const ( + UpstreamTypeJsonRpc UpstreamType = "jsonrpc" +) + +// JsonRpcNetworkConfig identifies a non-EVM JSON-RPC network (Solana, Starknet, …). +// Network id becomes jsonrpc:. No chainId / state poller / EVM method hooks. +type JsonRpcNetworkConfig struct { + // Id is a stable slug (usually the CLL alias), e.g. solana-mainnet. + Id string `yaml:"id" json:"id"` +} diff --git a/common/architecture_jsonrpc_test.go b/common/architecture_jsonrpc_test.go new file mode 100644 index 000000000..3047e3b25 --- /dev/null +++ b/common/architecture_jsonrpc_test.go @@ -0,0 +1,63 @@ +package common + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestJsonRpcNetworkId(t *testing.T) { + n := &NetworkConfig{ + Architecture: ArchitectureJsonRpc, + Alias: "solana-mainnet", + JsonRpc: &JsonRpcNetworkConfig{Id: "solana-mainnet"}, + } + if got := n.NetworkId(); got != "jsonrpc:solana-mainnet" { + t.Fatalf("NetworkId()=%q", got) + } + if !IsValidArchitecture(string(ArchitectureJsonRpc)) { + t.Fatal("ArchitectureJsonRpc should be valid") + } + if !IsValidNetwork("jsonrpc:solana-mainnet") { + t.Fatal("jsonrpc:solana-mainnet should be valid") + } + if IsValidNetwork("jsonrpc:") || IsValidNetwork("jsonrpc:a:b") { + t.Fatal("invalid jsonrpc ids accepted") + } +} + +func TestJsonRpcNetworkConfig_UnmarshalYAML_FailsafeObject(t *testing.T) { + // Chart emits failsafe as a single object (not a list). NetworkConfig must + // still accept architecture/jsonRpc via the oldNetworkConfig fallback. + const raw = ` +architecture: jsonrpc +alias: solana-mainnet +jsonRpc: + id: solana-mainnet +failsafe: + timeout: + duration: 30s + retry: + maxAttempts: 5 + delay: 50ms +` + var n NetworkConfig + if err := yaml.Unmarshal([]byte(raw), &n); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if n.Architecture != ArchitectureJsonRpc { + t.Fatalf("architecture=%q", n.Architecture) + } + if n.JsonRpc == nil || n.JsonRpc.Id != "solana-mainnet" { + t.Fatalf("jsonRpc=%v", n.JsonRpc) + } + if n.Alias != "solana-mainnet" { + t.Fatalf("alias=%q", n.Alias) + } + if len(n.Failsafe) != 1 || n.Failsafe[0].Timeout == nil { + t.Fatalf("failsafe not converted from object: %+v", n.Failsafe) + } + if got := n.NetworkId(); got != "jsonrpc:solana-mainnet" { + t.Fatalf("NetworkId()=%q", got) + } +} diff --git a/common/config.go b/common/config.go index f392e488d..5440992b1 100644 --- a/common/config.go +++ b/common/config.go @@ -1123,6 +1123,9 @@ type JsonRpcUpstreamConfig struct { EnableGzip *bool `yaml:"enableGzip,omitempty" json:"enableGzip"` Headers map[string]string `yaml:"headers,omitempty" json:"headers"` ProxyPool string `yaml:"proxyPool,omitempty" json:"proxyPool"` + // NetworkId binds this upstream to architecture jsonrpc (slug only, e.g. solana-mainnet). + // Required when type is jsonrpc; skipped for EVM upstreams that already use jsonRpc for batch/headers. + NetworkId string `yaml:"networkId,omitempty" json:"networkId,omitempty"` } func (c *JsonRpcUpstreamConfig) Copy() *JsonRpcUpstreamConfig { @@ -2013,6 +2016,7 @@ type NetworkConfig struct { RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget"` Failsafe []*FailsafeConfig `yaml:"failsafe,omitempty" json:"failsafe"` Evm *EvmNetworkConfig `yaml:"evm,omitempty" json:"evm"` + JsonRpc *JsonRpcNetworkConfig `yaml:"jsonRpc,omitempty" json:"jsonRpc"` SelectionPolicy *SelectionPolicyConfig `yaml:"selectionPolicy,omitempty" json:"selectionPolicy"` DirectiveDefaults *DirectiveDefaultsConfig `yaml:"directiveDefaults,omitempty" json:"directiveDefaults"` Alias string `yaml:"alias,omitempty" json:"alias"` @@ -2084,6 +2088,7 @@ func (n *NetworkConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { RateLimitBudget string `yaml:"rateLimitBudget,omitempty"` Failsafe *FailsafeConfig `yaml:"failsafe,omitempty"` Evm *EvmNetworkConfig `yaml:"evm,omitempty"` + JsonRpc *JsonRpcNetworkConfig `yaml:"jsonRpc,omitempty"` SelectionPolicy *SelectionPolicyConfig `yaml:"selectionPolicy,omitempty"` DirectiveDefaults *DirectiveDefaultsConfig `yaml:"directiveDefaults,omitempty"` Alias string `yaml:"alias,omitempty"` @@ -2102,6 +2107,7 @@ func (n *NetworkConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { n.Architecture = old.Architecture n.RateLimitBudget = old.RateLimitBudget n.Evm = old.Evm + n.JsonRpc = old.JsonRpc n.SelectionPolicy = old.SelectionPolicy n.DirectiveDefaults = old.DirectiveDefaults n.Alias = old.Alias @@ -2389,11 +2395,12 @@ func (s *SelectionPolicyConfig) UnmarshalYAML(unmarshal func(interface{}) error) type AuthType string const ( - AuthTypeSecret AuthType = "secret" - AuthTypeDatabase AuthType = "database" - AuthTypeJwt AuthType = "jwt" - AuthTypeSiwe AuthType = "siwe" - AuthTypeNetwork AuthType = "network" + AuthTypeSecret AuthType = "secret" + AuthTypeDatabase AuthType = "database" + AuthTypeJwt AuthType = "jwt" + AuthTypeSiwe AuthType = "siwe" + AuthTypeNetwork AuthType = "network" + AuthTypeForwardedClientId AuthType = "forwardedClientId" ) type AuthConfig struct { @@ -2405,12 +2412,27 @@ type AuthStrategyConfig struct { AllowMethods []string `yaml:"allowMethods,omitempty" json:"allowMethods,omitempty"` RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget,omitempty"` - Type AuthType `yaml:"type" json:"type" tstype:"TsAuthType"` - Network *NetworkStrategyConfig `yaml:"network,omitempty" json:"network,omitempty"` - Secret *SecretStrategyConfig `yaml:"secret,omitempty" json:"secret,omitempty"` - Database *DatabaseStrategyConfig `yaml:"database,omitempty" json:"database,omitempty"` - Jwt *JwtStrategyConfig `yaml:"jwt,omitempty" json:"jwt,omitempty"` - Siwe *SiweStrategyConfig `yaml:"siwe,omitempty" json:"siwe,omitempty"` + Type AuthType `yaml:"type" json:"type" tstype:"TsAuthType"` + Network *NetworkStrategyConfig `yaml:"network,omitempty" json:"network,omitempty"` + Secret *SecretStrategyConfig `yaml:"secret,omitempty" json:"secret,omitempty"` + Database *DatabaseStrategyConfig `yaml:"database,omitempty" json:"database,omitempty"` + Jwt *JwtStrategyConfig `yaml:"jwt,omitempty" json:"jwt,omitempty"` + Siwe *SiweStrategyConfig `yaml:"siwe,omitempty" json:"siwe,omitempty"` + ForwardedClientId *ForwardedClientIdStrategyConfig `yaml:"forwardedClientId,omitempty" json:"forwardedClientId,omitempty"` +} + +// ForwardedClientIdStrategyConfig trusts a non-secret client identity header +// injected by an upstream gateway after API-key auth (e.g. Envoy +// apiKeyAuth.forwardClientIDHeader → X-Client-Id). Must only be enabled +// behind a gateway that overwrites/strips client-supplied values of that header. +type ForwardedClientIdStrategyConfig struct { + // Header documents the expected gateway identity header (default conceptually + // "X-Client-Id"). Payload extraction in auth.NewPayloadFromHttp currently + // always reads X-Client-Id via case-insensitive Header.Get; this field is + // not yet used to select the header name at runtime. + Header string `yaml:"header,omitempty" json:"header,omitempty"` + // RateLimitBudget, if set, is applied to the authenticated user. + RateLimitBudget string `yaml:"rateLimitBudget,omitempty" json:"rateLimitBudget,omitempty"` } type SecretStrategyConfig struct { @@ -2546,13 +2568,21 @@ type RateLimitStoreConfig struct { } func (c *NetworkConfig) NetworkId() string { - if c.Architecture == "" || c.Evm == nil { + if c.Architecture == "" { return "" } switch c.Architecture { - case "evm": + case ArchitectureEvm: + if c.Evm == nil { + return "" + } return util.EvmNetworkId(c.Evm.ChainId) + case ArchitectureJsonRpc: + if c.JsonRpc == nil || c.JsonRpc.Id == "" { + return "" + } + return util.JsonRpcNetworkId(c.JsonRpc.Id) default: return "" } diff --git a/common/defaults.go b/common/defaults.go index 19fcecc18..3f1f6a0cc 100644 --- a/common/defaults.go +++ b/common/defaults.go @@ -1646,8 +1646,17 @@ func (u *UpstreamConfig) SetDefaults(defaults *UpstreamConfig) error { } } if u.Type == "" { - // TODO make actual calls to detect other types (solana, btc, etc)? - u.Type = UpstreamTypeEvm + if u.JsonRpc != nil && u.JsonRpc.NetworkId != "" { + u.Type = UpstreamTypeJsonRpc + } else { + // TODO make actual calls to detect other types (solana, btc, etc)? + u.Type = UpstreamTypeEvm + } + } + if u.Type == UpstreamTypeJsonRpc { + if u.JsonRpc == nil { + u.JsonRpc = &JsonRpcUpstreamConfig{} + } } if len(u.Failsafe) > 0 { @@ -1940,13 +1949,21 @@ func (n *NetworkConfig) SetDefaults(upstreams []*UpstreamConfig, defaults *Netwo if n.Architecture == "" { if n.Evm != nil { - n.Architecture = "evm" + n.Architecture = ArchitectureEvm + } else if n.JsonRpc != nil && n.JsonRpc.Id != "" { + n.Architecture = ArchitectureJsonRpc } } - if n.Architecture == "evm" && n.Evm == nil { + if n.Architecture == ArchitectureEvm && n.Evm == nil { n.Evm = &EvmNetworkConfig{} } + if n.Architecture == ArchitectureJsonRpc && n.JsonRpc == nil { + n.JsonRpc = &JsonRpcNetworkConfig{} + } + if n.Architecture == ArchitectureJsonRpc && n.JsonRpc.Id == "" && n.Alias != "" { + n.JsonRpc.Id = n.Alias + } // Apply methods defaults if n.Methods == nil { @@ -2681,6 +2698,23 @@ func (s *AuthStrategyConfig) SetDefaults() error { } } + if s.Type == AuthTypeForwardedClientId && s.ForwardedClientId == nil { + s.ForwardedClientId = &ForwardedClientIdStrategyConfig{} + } + if s.ForwardedClientId != nil { + s.Type = AuthTypeForwardedClientId + if err := s.ForwardedClientId.SetDefaults(); err != nil { + return fmt.Errorf("failed to set defaults for forwardedClientId strategy: %w", err) + } + } + + return nil +} + +func (s *ForwardedClientIdStrategyConfig) SetDefaults() error { + if s.Header == "" { + s.Header = "X-Client-Id" + } return nil } diff --git a/common/json_rpc.go b/common/json_rpc.go index 027799441..5a0021939 100644 --- a/common/json_rpc.go +++ b/common/json_rpc.go @@ -371,7 +371,10 @@ func (r *JsonRpcResponse) ParseFromStream(ctx []context.Context, reader io.Reade } } - if len(temp.Error) > 0 { + // Bitcoin-family nodes often emit `"error": null` on success. Treat that + // (and missing error) as no error — ParseError("null") used to invent a + // server-side exception and fail the whole upstream attempt. + if len(temp.Error) > 0 && string(temp.Error) != "null" { if err := r.ParseError(string(temp.Error)); err != nil { return err } @@ -399,11 +402,17 @@ func (r *JsonRpcResponse) ParseError(raw string) error { r.errBytes = nil + // JSON-RPC allows "error": null (Bitcoin Core / dogecoind / litecoind). + // That means success — do not fabricate a server-side exception. + if raw == "null" { + return nil + } + // First attempt to unmarshal the error as a typical JSON-RPC error var rpcErr ErrJsonRpcExceptionExternal if err := SonicCfg.UnmarshalFromString(raw, &rpcErr); err != nil { // Special case: check for non-standard error structures in the raw data - if raw == "" || raw == "null" { + if raw == "" { r.Error = NewErrJsonRpcExceptionExternal( int(JsonRpcErrorServerSideException), "unexpected empty response from upstream endpoint", @@ -1205,7 +1214,10 @@ type JsonRpcRequest struct { JSONRPC string `json:"jsonrpc,omitempty"` ID interface{} `json:"id,omitempty"` Method string `json:"method"` - Params []interface{} `json:"params"` + // omitempty: Stellar (and some other non-EVM) reject "params":[] — they + // expect the field absent (or an object). Empty/nil params are omitted on + // the wire; EVM nodes accept both forms. + Params []interface{} `json:"params,omitempty"` // idRaw stores the verbatim bytes of the id as received from the client. // This is used to round-trip the id back without precision loss for ids diff --git a/common/json_rpc_test.go b/common/json_rpc_test.go index f2b5c72da..183be0902 100644 --- a/common/json_rpc_test.go +++ b/common/json_rpc_test.go @@ -219,10 +219,23 @@ func TestJsonRpcRequest_MarshalParams(t *testing.T) { }) assert.NoError(t, err) - expectedRawReq := `{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}` + // Empty params omitted — required for Stellar / some non-EVM nodes + // that reject "params":[] (expect absent field or object). + expectedRawReq := `{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber"}` assert.Equal(t, expectedRawReq, string(rawReq)) }) + t.Run("Nil", func(t *testing.T) { + rawReq, err := SonicCfg.Marshal(JsonRpcRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "getLatestLedger", + Params: nil, + }) + assert.NoError(t, err) + assert.Equal(t, `{"jsonrpc":"2.0","id":1,"method":"getLatestLedger"}`, string(rawReq)) + }) + t.Run("Value", func(t *testing.T) { rawReq, err := SonicCfg.Marshal(JsonRpcRequest{ JSONRPC: "2.0", @@ -237,6 +250,20 @@ func TestJsonRpcRequest_MarshalParams(t *testing.T) { }) } +func TestJsonRpcResponse_ErrorNullIsSuccess(t *testing.T) { + // Bitcoin-family (dogecoind/litecoind) returns "error":null on success. + raw := `{"result":67851510,"error":null,"id":1}` + r := &JsonRpcResponse{} + err := r.ParseFromStream(nil, bytes.NewReader([]byte(raw)), len(raw)) + require.NoError(t, err) + assert.Nil(t, r.Error) + assert.Equal(t, "67851510", string(r.result)) + + r2 := &JsonRpcResponse{} + require.NoError(t, r2.ParseError("null")) + assert.Nil(t, r2.Error) +} + func TestJsonRpcResponse_CanonicalHash_EmptyishNormalization(t *testing.T) { // Test cases that should produce the same hash due to emptyish normalization testGroups := []struct { diff --git a/common/network.go b/common/network.go index 44a843c67..72ff622f3 100644 --- a/common/network.go +++ b/common/network.go @@ -12,7 +12,8 @@ import ( type NetworkArchitecture string const ( - ArchitectureEvm NetworkArchitecture = "evm" + ArchitectureEvm NetworkArchitecture = "evm" + ArchitectureJsonRpc NetworkArchitecture = "jsonrpc" ) type Network interface { @@ -33,7 +34,12 @@ type Network interface { } func IsValidArchitecture(architecture string) bool { - return architecture == string(ArchitectureEvm) // TODO add more architectures when they are supported + switch NetworkArchitecture(architecture) { + case ArchitectureEvm, ArchitectureJsonRpc: + return true + default: + return false + } } func IsValidNetwork(network string) bool { @@ -44,6 +50,10 @@ func IsValidNetwork(network string) bool { } return chainId > 0 } + if strings.HasPrefix(network, "jsonrpc:") { + id := strings.TrimPrefix(network, "jsonrpc:") + return id != "" && !strings.Contains(id, ":") + } return false } diff --git a/common/request.go b/common/request.go index f7dd6161c..7bd3231b9 100644 --- a/common/request.go +++ b/common/request.go @@ -360,6 +360,10 @@ type NormalizedRequest struct { // Resolved client IP (set by HTTP ingress using trusted forwarders) clientIP atomic.Value + // Client transport that delivered this request ("http" or "ws"). + // Defaults to "http" when unset so HTTP ingress needs no explicit set. + transport atomic.Value + // Per-request execution counters; lazy-init via execStateHolder. execStateHolder execStateHolder } @@ -1322,6 +1326,27 @@ func (r *NormalizedRequest) AgentName() string { return "unknown" } +// SetTransport records the client ingress transport ("http" or "ws"). +func (r *NormalizedRequest) SetTransport(transport string) { + if r == nil || transport == "" { + return + } + r.transport.Store(transport) +} + +// Transport returns the client ingress transport. Defaults to "http". +func (r *NormalizedRequest) Transport() string { + if r == nil { + return "http" + } + if v := r.transport.Load(); v != nil { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return "http" +} + // getUserAgent returns the user agent string, with query parameter taking precedence over header func (r *NormalizedRequest) getUserAgent(headers http.Header, queryArgs url.Values) string { // Query parameter takes precedence diff --git a/common/validation.go b/common/validation.go index 2143b03ec..86ecdd3d0 100644 --- a/common/validation.go +++ b/common/validation.go @@ -729,6 +729,13 @@ func (s *AuthStrategyConfig) Validate() error { if err := s.Database.Validate(); err != nil { return err } + case AuthTypeForwardedClientId: + if s.ForwardedClientId == nil { + return fmt.Errorf("auth.*.forwardedClientId is required for forwardedClientId strategy") + } + if err := s.ForwardedClientId.Validate(); err != nil { + return err + } default: return fmt.Errorf("auth.*.type '%s' is invalid must be one of: %v", s.Type, []AuthType{ AuthTypeNetwork, @@ -736,11 +743,19 @@ func (s *AuthStrategyConfig) Validate() error { AuthTypeJwt, AuthTypeSiwe, AuthTypeDatabase, + AuthTypeForwardedClientId, }) } return nil } +func (s *ForwardedClientIdStrategyConfig) Validate() error { + if s == nil { + return fmt.Errorf("auth.*.forwardedClientId is required") + } + return nil +} + func (s *DatabaseStrategyConfig) Validate() error { if s.Connector == nil { return fmt.Errorf("auth.*.database.connector is required") @@ -769,7 +784,13 @@ func (s *NetworkStrategyConfig) Validate() error { } func (s *SecretStrategyConfig) Validate() error { - if s.Value == "" { + if s == nil { + return fmt.Errorf("auth.*.secret is required") + } + if strings.TrimSpace(s.Id) == "" { + return fmt.Errorf("auth.*.secret.id is required") + } + if strings.TrimSpace(s.Value) == "" { return fmt.Errorf("auth.*.secret.value is required") } return nil @@ -835,6 +856,14 @@ func (u *UpstreamConfig) Validate(c *Config, skipEndpointCheck bool) error { if !skipEndpointCheck && u.Endpoint == "" { return fmt.Errorf("upstream.*.endpoint is required") } + if u.Type == UpstreamTypeJsonRpc { + if u.JsonRpc == nil || u.JsonRpc.NetworkId == "" { + return fmt.Errorf("upstream.*.jsonRpc.networkId is required for type jsonrpc") + } + if !util.IsValidIdentifier(u.JsonRpc.NetworkId) { + return fmt.Errorf("upstream.*.jsonRpc.networkId '%s' is invalid", u.JsonRpc.NetworkId) + } + } if u.Evm != nil { if err := u.Evm.Validate(u); err != nil { return err @@ -1253,9 +1282,17 @@ func (n *NetworkConfig) Validate(c *Config) error { if n.Architecture == "" { return fmt.Errorf("network.*.architecture is required") } - if n.Architecture == "evm" && n.Evm == nil { + if n.Architecture == ArchitectureEvm && n.Evm == nil { return fmt.Errorf("network.*.evm is required for evm networks") } + if n.Architecture == ArchitectureJsonRpc { + if n.JsonRpc == nil || n.JsonRpc.Id == "" { + return fmt.Errorf("network.*.jsonRpc.id is required for jsonrpc networks") + } + if !util.IsValidIdentifier(n.JsonRpc.Id) { + return fmt.Errorf("network.*.jsonRpc.id '%s' must contain only alphanumeric characters, dash, or underscore", n.JsonRpc.Id) + } + } if n.Evm != nil { if err := n.Evm.Validate(); err != nil { return err diff --git a/erpc/healthcheck.go b/erpc/healthcheck.go index 85e4953b5..ce81bb596 100644 --- a/erpc/healthcheck.go +++ b/erpc/healthcheck.go @@ -86,7 +86,7 @@ func (s *HttpServer) handleHealthCheck( headers := r.Header queryArgs := r.URL.Query() - ap, err := auth.NewPayloadFromHttp("healthcheck", r.RemoteAddr, headers, queryArgs) + ap, err := auth.NewPayloadFromHttp("healthcheck", r.RemoteAddr, headers, queryArgs, r.URL.Path) if err != nil { handleErrorResponse(ctx, &logger, startedAt, nil, err, w, encoder, writeFatalError, &common.TRUE, s.executionHeadersMode()) return diff --git a/erpc/http_server.go b/erpc/http_server.go index 2616118eb..9c91cfbf6 100644 --- a/erpc/http_server.go +++ b/erpc/http_server.go @@ -574,9 +574,9 @@ func (s *HttpServer) createRequestHandler() http.Handler { var err error if project != nil { - ap, err = auth.NewPayloadFromHttp(method, r.RemoteAddr, headers, queryArgs) + ap, err = auth.NewPayloadFromHttp(method, r.RemoteAddr, headers, queryArgs, r.URL.Path) } else if isAdmin { - ap, err = auth.NewPayloadFromHttp(method, r.RemoteAddr, headers, queryArgs) + ap, err = auth.NewPayloadFromHttp(method, r.RemoteAddr, headers, queryArgs, r.URL.Path) } if err != nil { responses[index] = processErrorBody(&rlg, &startedAt, nq, err, &common.TRUE) diff --git a/erpc/http_server_test.go b/erpc/http_server_test.go index 3b5aa7912..9377760cd 100644 --- a/erpc/http_server_test.go +++ b/erpc/http_server_test.go @@ -4112,7 +4112,7 @@ func TestHttpServer_HandleHealthCheck(t *testing.T) { pp.networksRegistry = NewNetworksRegistry(pp, ctx, pp.upstreamsRegistry, mtk, nil, nil, nil, logger) authReg, _ := auth.NewAuthRegistry(ctx, logger, "test", &common.AuthConfig{Strategies: []*common.AuthStrategyConfig{ - {Type: common.AuthTypeSecret, Secret: &common.SecretStrategyConfig{Value: "test-secret"}}, + {Type: common.AuthTypeSecret, Secret: &common.SecretStrategyConfig{Id: "test-user", Value: "test-secret"}}, }}, nil) return &HttpServer{ diff --git a/erpc/networks.go b/erpc/networks.go index 6a6374176..ab7ea550b 100644 --- a/erpc/networks.go +++ b/erpc/networks.go @@ -1392,6 +1392,17 @@ func (n *Network) prepareRequest(ctx context.Context, nr *common.NormalizedReque ) } evm.NormalizeHttpJsonRpc(ctx, nr, jsonRpcReq) + case common.ArchitectureJsonRpc: + // Passthrough: parse/validate JSON-RPC envelope only — no EVM method hooks. + if _, err := nr.JsonRpcRequest(ctx); err != nil { + return common.NewErrJsonRpcExceptionInternal( + 0, + common.JsonRpcErrorParseException, + "failed to unmarshal json-rpc request", + err, + nil, + ) + } default: return common.NewErrJsonRpcExceptionInternal( 0, diff --git a/erpc/networks_registry.go b/erpc/networks_registry.go index 4183ec944..5246a8af2 100644 --- a/erpc/networks_registry.go +++ b/erpc/networks_registry.go @@ -309,10 +309,12 @@ func (nr *NetworksRegistry) prepareNetwork(nwCfg *common.NetworkConfig) (*Networ } switch nwCfg.Architecture { - case "evm": + case common.ArchitectureEvm: if nr.evmJsonRpcCache != nil { network.cacheDal = nr.evmJsonRpcCache.WithProjectId(nr.project.Config.Id) } + case common.ArchitectureJsonRpc: + // No architecture-specific cache yet; failsafe + metrics still apply. default: return nil, errors.New("unknown network architecture") } @@ -363,6 +365,11 @@ func (nr *NetworksRegistry) resolveNetworkConfig(networkId string) (*common.Netw return nil, e } nwCfg.Evm = &common.EvmNetworkConfig{ChainId: int64(c)} + case common.ArchitectureJsonRpc: + if !util.IsValidIdentifier(s[1]) { + return nil, fmt.Errorf("invalid jsonrpc network id: %s", networkId) + } + nwCfg.JsonRpc = &common.JsonRpcNetworkConfig{Id: s[1]} } if err := nwCfg.SetDefaults(prj.Config.Upstreams, prj.Config.NetworkDefaults); err != nil { return nil, fmt.Errorf("failed to set defaults for network config: %w", err) diff --git a/erpc/projects.go b/erpc/projects.go index 1c601a7c7..421234cfe 100644 --- a/erpc/projects.go +++ b/erpc/projects.go @@ -121,7 +121,7 @@ func (p *PreparedProject) Forward(ctx context.Context, networkId string, nq *com reqFinality := nq.Finality(ctx) telemetry.CounterHandle(telemetry.MetricNetworkRequestsReceived, - p.Config.Id, network.Label(), method, reqFinality.String(), nq.UserId(), nq.AgentName(), + p.Config.Id, network.Label(), method, reqFinality.String(), nq.UserId(), nq.AgentName(), nq.Transport(), ).Inc() lg := p.Logger.With(). Str("component", "proxy"). diff --git a/erpc/subscription_manager.go b/erpc/subscription_manager.go index 2c05546ca..c10f47594 100644 --- a/erpc/subscription_manager.go +++ b/erpc/subscription_manager.go @@ -152,7 +152,7 @@ func (sm *SubscriptionManager) Subscribe( reqFinality := nq.Finality(ctx) telemetry.CounterHandle(telemetry.MetricNetworkRequestsReceived, project.Config.Id, nw.Label(), method, - reqFinality.String(), nq.UserId(), nq.AgentName(), + reqFinality.String(), nq.UserId(), nq.AgentName(), nq.Transport(), ).Inc() jrReq, err := nq.JsonRpcRequest() @@ -174,7 +174,12 @@ func (sm *SubscriptionManager) Subscribe( return nil, err } - conn.adapter.AddSubscription(clientSubID, networkId, kind, filterHash) + conn.adapter.AddSubscription(clientSubID, networkId, kind, filterHash, wsclient.SubscriptionLabels{ + Project: project.Config.Id, + Network: nw.Label(), + User: nq.UserId(), + AgentName: nq.AgentName(), + }) sm.bySubID.Store(clientSubID, &subRecord{ clientSubID: clientSubID, connID: wsc.id, @@ -226,7 +231,7 @@ func (sm *SubscriptionManager) Unsubscribe( reqFinality := nq.Finality(ctx) telemetry.CounterHandle(telemetry.MetricNetworkRequestsReceived, project.Config.Id, nw.Label(), method, - reqFinality.String(), nq.UserId(), nq.AgentName(), + reqFinality.String(), nq.UserId(), nq.AgentName(), nq.Transport(), ).Inc() jrReq, err := nq.JsonRpcRequest() diff --git a/erpc/ws_server.go b/erpc/ws_server.go index e3c53354e..eb06d000b 100644 --- a/erpc/ws_server.go +++ b/erpc/ws_server.go @@ -224,6 +224,7 @@ func (wsc *WsConnection) handleMessage(raw []byte) { func (wsc *WsConnection) handleSingleRequest(raw []byte, startedAt *time.Time) { nq := common.NewNormalizedRequest(raw) + nq.SetTransport("ws") nq.ForwardHeaders = make(http.Header) requestCtx := common.StartRequestSpan(wsc.appCtx, nq) @@ -342,7 +343,7 @@ func (wsc *WsConnection) authenticate(requestCtx context.Context, nq *common.Nor return nil } - ap, err := auth.NewPayloadFromHttp(method, wsc.httpReq.RemoteAddr, wsc.httpReq.Header, wsc.httpReq.URL.Query()) + ap, err := auth.NewPayloadFromHttp(method, wsc.httpReq.RemoteAddr, wsc.httpReq.Header, wsc.httpReq.URL.Query(), wsc.httpReq.URL.Path) if err != nil { return err } @@ -427,6 +428,7 @@ func (wsc *WsConnection) handleBatch(raw []byte, startedAt *time.Time) { // connection context. func (wsc *WsConnection) handleBatchItem(index int, reqRaw json.RawMessage, startedAt *time.Time, responses []interface{}) { nq := common.NewNormalizedRequest(reqRaw) + nq.SetTransport("ws") nq.ForwardHeaders = make(http.Header) requestCtx := common.StartRequestSpan(wsc.appCtx, nq) @@ -455,7 +457,7 @@ func (wsc *WsConnection) handleBatchItem(index int, reqRaw json.RawMessage, star } if wsc.project != nil { - ap, err := auth.NewPayloadFromHttp(method, wsc.httpReq.RemoteAddr, wsc.httpReq.Header, wsc.httpReq.URL.Query()) + ap, err := auth.NewPayloadFromHttp(method, wsc.httpReq.RemoteAddr, wsc.httpReq.Header, wsc.httpReq.URL.Query(), wsc.httpReq.URL.Path) if err != nil { responses[index] = processErrorBody(wsc.logger, startedAt, nq, err, &common.TRUE) common.EndRequestSpan(requestCtx, nil, err) diff --git a/indexer/adapters/wsclient/adapter.go b/indexer/adapters/wsclient/adapter.go index 3841404e9..9eb2d3f85 100644 --- a/indexer/adapters/wsclient/adapter.go +++ b/indexer/adapters/wsclient/adapter.go @@ -12,6 +12,7 @@ import ( "sync/atomic" "github.com/erpc/erpc/indexer" + "github.com/erpc/erpc/telemetry" "github.com/rs/zerolog" ) @@ -61,12 +62,23 @@ type routeKey struct { filterHash string } +// SubscriptionLabels are frozen at eth_subscribe time for per-client metrics +// on delivered / dropped notification events. +type SubscriptionLabels struct { + Project string + // Network is the metrics network label (alias if configured, else network id). + Network string + User string + AgentName string +} + type clientSub struct { id string kind indexer.EventKind networkID string // filterHash is "" for newHeads. filterHash string + labels SubscriptionLabels notify chan json.RawMessage done chan struct{} @@ -129,13 +141,23 @@ func (a *Adapter) Deliver(ev indexer.IndexedEvent) { // AddSubscription registers a client subscription on this connection and // starts its writer goroutine. clientSubId is the erpc-generated opaque // ID the caller already returned to the client. filterHash is "" for -// newHeads. -func (a *Adapter) AddSubscription(clientSubID, networkID string, kind indexer.EventKind, filterHash string) { +// newHeads. labels are used for Prometheus counters on deliver/drop. +func (a *Adapter) AddSubscription(clientSubID, networkID string, kind indexer.EventKind, filterHash string, labels SubscriptionLabels) { + if labels.Project == "" { + labels.Project = "n/a" + } + if labels.User == "" { + labels.User = "n/a" + } + if labels.AgentName == "" { + labels.AgentName = "unknown" + } sub := &clientSub{ id: clientSubID, kind: kind, networkID: networkID, filterHash: filterHash, + labels: labels, notify: make(chan json.RawMessage, clientNotifyBufferSize), done: make(chan struct{}), } @@ -246,11 +268,35 @@ func (a *Adapter) runWriter(sub *clientSub) { Msg("failed to write subscription notification") // Errors are per-sub; the connection-close path will // Drain us when the peer is truly gone. + continue } + incWsSubscriptionEvent(sub, false) } } } +func subscriptionNetworkLabel(sub *clientSub) string { + if sub.labels.Network != "" { + return sub.labels.Network + } + return sub.networkID +} + +func incWsSubscriptionEvent(sub *clientSub, dropped bool) { + labels := []string{ + sub.labels.Project, + subscriptionNetworkLabel(sub), + sub.kind.String(), + sub.labels.User, + sub.labels.AgentName, + } + if dropped { + telemetry.MetricWsSubscriptionEventsDroppedTotal.WithLabelValues(labels...).Inc() + } else { + telemetry.MetricWsSubscriptionEventsTotal.WithLabelValues(labels...).Inc() + } +} + // enqueue pushes a payload onto the sub's buffer, evicting the oldest // element when the buffer is full. Never blocks. func enqueue(sub *clientSub, payload json.RawMessage) { @@ -262,8 +308,10 @@ func enqueue(sub *clientSub, payload json.RawMessage) { // Buffer full; drop oldest to make room. select { case <-sub.notify: + incWsSubscriptionEvent(sub, true) default: // Concurrent drain won the race — drop this message. + incWsSubscriptionEvent(sub, true) return } } diff --git a/telemetry/metrics.go b/telemetry/metrics.go index e7e05f56e..aadeb8e49 100644 --- a/telemetry/metrics.go +++ b/telemetry/metrics.go @@ -88,6 +88,22 @@ var ( Help: "Whether the upstream WebSocket connection is currently established (1) or down/wedged (0).", }, []string{"project", "vendor", "network", "upstream"}) + // Client-facing WebSocket subscription push events (newHeads / logs / + // pending txs) successfully written to a downstream client. Internal + // ops metric — CLL rpc_ws_event_count_total maps to request_received + // with transport="ws", not these pushes. + MetricWsSubscriptionEventsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "erpc", + Name: "ws_subscription_events_total", + Help: "Subscription notifications successfully written to a client WebSocket.", + }, []string{"project", "network", "kind", "user", "agent_name"}) + + MetricWsSubscriptionEventsDroppedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "erpc", + Name: "ws_subscription_events_dropped_total", + Help: "Subscription notifications dropped due to slow-client buffer overflow.", + }, []string{"project", "network", "kind", "user", "agent_name"}) + MetricUpstreamCordoned = promauto.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "erpc", Name: "upstream_cordoned", @@ -335,7 +351,7 @@ var ( Namespace: "erpc", Name: "network_request_received_total", Help: "Total number of requests received for a network.", - }, []string{"project", "network", "category", "finality", "user", "agent_name"}) + }, []string{"project", "network", "category", "finality", "user", "agent_name", "transport"}) MetricNetworkMultiplexedRequests = promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "erpc", diff --git a/typescript/config/src/generated.ts b/typescript/config/src/generated.ts index 6fe14b957..6bbb7f8cc 100644 --- a/typescript/config/src/generated.ts +++ b/typescript/config/src/generated.ts @@ -1296,6 +1296,7 @@ export const AuthTypeDatabase: AuthType = "database"; export const AuthTypeJwt: AuthType = "jwt"; export const AuthTypeSiwe: AuthType = "siwe"; export const AuthTypeNetwork: AuthType = "network"; +export const AuthTypeForwardedClientId: AuthType = "forwardedClientId"; export interface AuthConfig { strategies: TsAuthStrategyConfig[]; } @@ -1309,6 +1310,17 @@ export interface AuthStrategyConfig { database?: DatabaseStrategyConfig; jwt?: JwtStrategyConfig; siwe?: SiweStrategyConfig; + /** + * Trust a gateway-injected client id header (e.g. Envoy X-Client-Id). + */ + forwardedClientId?: ForwardedClientIdStrategyConfig; +} +export interface ForwardedClientIdStrategyConfig { + /** + * Header carrying the client id. Default: "X-Client-Id". + */ + header?: string; + rateLimitBudget?: string; } export interface SecretStrategyConfig { id: string; diff --git a/typescript/config/src/index.ts b/typescript/config/src/index.ts index 2b3bd643d..f8ad24b61 100644 --- a/typescript/config/src/index.ts +++ b/typescript/config/src/index.ts @@ -66,6 +66,7 @@ export { AuthTypeJwt, AuthTypeSiwe, AuthTypeNetwork, + AuthTypeForwardedClientId, // Consensus related ConsensusLowParticipantsBehaviorReturnError, ConsensusLowParticipantsBehaviorAcceptMostCommonValidResult, diff --git a/upstream/registry.go b/upstream/registry.go index 6c5f43b1b..8b31f09b0 100644 --- a/upstream/registry.go +++ b/upstream/registry.go @@ -474,6 +474,8 @@ func (u *UpstreamsRegistry) buildUpstreamBootstrapTask(upsCfg *common.UpstreamCo taskName := fmt.Sprintf("upstream/%s", cfg.Id) if cfg.Evm != nil && cfg.Evm.ChainId > 0 { taskName = fmt.Sprintf("network/%s/upstream/%s", util.EvmNetworkId(cfg.Evm.ChainId), cfg.Id) + } else if cfg.Type == common.UpstreamTypeJsonRpc && cfg.JsonRpc != nil && cfg.JsonRpc.NetworkId != "" { + taskName = fmt.Sprintf("network/%s/upstream/%s", util.JsonRpcNetworkId(cfg.JsonRpc.NetworkId), cfg.Id) } return util.NewBootstrapTask( taskName, diff --git a/upstream/upstream.go b/upstream/upstream.go index d7b294636..21fae355c 100644 --- a/upstream/upstream.go +++ b/upstream/upstream.go @@ -1487,6 +1487,14 @@ func (u *Upstream) detectFeatures(ctx context.Context) error { // TODO evm: check trace methods availability (by engine? erigon/geth/etc) // TODO evm: detect max eth_getLogs max block range + } else if cfg.Type == common.UpstreamTypeJsonRpc { + if cfg.JsonRpc == nil || cfg.JsonRpc.NetworkId == "" { + return common.NewTaskFatal(fmt.Errorf("upstream.*.jsonRpc.networkId is required for type jsonrpc")) + } + if !util.IsValidIdentifier(cfg.JsonRpc.NetworkId) { + return common.NewTaskFatal(fmt.Errorf("upstream.*.jsonRpc.networkId '%s' is invalid", cfg.JsonRpc.NetworkId)) + } + u.networkId.Store(util.JsonRpcNetworkId(cfg.JsonRpc.NetworkId)) } else { return fmt.Errorf("upstream type not supported: %s", cfg.Type) } diff --git a/util/ids.go b/util/ids.go index 0b6bc9f77..433b16f86 100644 --- a/util/ids.go +++ b/util/ids.go @@ -12,6 +12,10 @@ func EvmNetworkId(chainId interface{}) string { return fmt.Sprintf("evm:%d", chainId) } +func JsonRpcNetworkId(id string) string { + return fmt.Sprintf("jsonrpc:%s", id) +} + var validIdentifierRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) func IsValidIdentifier(s string) bool { @@ -23,6 +27,10 @@ func IsValidNetworkId(s string) bool { _, err := strconv.Atoi(s[4:]) return err == nil } + if strings.HasPrefix(s, "jsonrpc:") { + id := s[len("jsonrpc:"):] + return id != "" && IsValidIdentifier(id) + } return false }