diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4c7e6f87d..6290eafe8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -14,7 +14,7 @@ builds: ignore: - goos: windows goarch: arm64 - main: ./cmd/erpc/main.go + main: ./cmd/erpc ldflags: - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} diff --git a/Dockerfile b/Dockerfile index 23163193f..fcca1541e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,8 +30,8 @@ ENV CGO_ENABLED=0 \ LDFLAGS="-w -s -X github.com/erpc/erpc/common.ErpcVersion=${VERSION} -X github.com/erpc/erpc/common.ErpcCommitSha=${COMMIT_SHA}" # Build the Go binary -RUN go build -v -ldflags="$LDFLAGS" -a -installsuffix cgo -o erpc-server ./cmd/erpc/main.go && \ - go build -v -ldflags="$LDFLAGS" -a -installsuffix cgo -tags pprof -o erpc-server-pprof ./cmd/erpc/*.go +RUN go build -v -ldflags="$LDFLAGS" -a -installsuffix cgo -o erpc-server ./cmd/erpc && \ + go build -v -ldflags="$LDFLAGS" -a -installsuffix cgo -tags pprof -o erpc-server-pprof ./cmd/erpc # Global typescript related image FROM node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 AS ts-core diff --git a/Makefile b/Makefile index d5ffd36db..4ba0703f7 100644 --- a/Makefile +++ b/Makefile @@ -23,11 +23,11 @@ setup: .PHONY: run run: - @go run ./cmd/erpc/main.go + @go run ./cmd/erpc .PHONY: run-pprof run-pprof: - @go run ./cmd/erpc/main.go ./cmd/erpc/pprof.go + @go run -tags pprof ./cmd/erpc .PHONY: run-fake-rpcs run-fake-rpcs: @@ -43,8 +43,8 @@ run-k6-evm-historical-randomized: .PHONY: build build: - @CGO_ENABLED=0 go build -ldflags="-w -s" -o ./bin/erpc-server ./cmd/erpc/main.go - @CGO_ENABLED=0 go build -ldflags="-w -s" -tags pprof -o ./bin/erpc-server-pprof ./cmd/erpc/*.go + @CGO_ENABLED=0 go build -ldflags="-w -s" -o ./bin/erpc-server ./cmd/erpc + @CGO_ENABLED=0 go build -ldflags="-w -s" -tags pprof -o ./bin/erpc-server-pprof ./cmd/erpc .PHONY: test test: diff --git a/cmd/erpc/auth_proxy.go b/cmd/erpc/auth_proxy.go new file mode 100644 index 000000000..f566fb6d8 --- /dev/null +++ b/cmd/erpc/auth_proxy.go @@ -0,0 +1,279 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + "path" + "strings" + "time" + + "github.com/erpc/erpc/auth" + "github.com/erpc/erpc/common" + "github.com/erpc/erpc/upstream" + "github.com/rs/zerolog" +) + +type authProxy struct { + proxy *httputil.ReverseProxy + projects map[string]*auth.AuthRegistry +} + +func runAuthProxy(ctx context.Context, logger zerolog.Logger, cfg *common.Config, upstreamURL string) error { + target, err := url.Parse(upstreamURL) + if err != nil { + return fmt.Errorf("invalid upstream URL: %w", err) + } + if target.Scheme == "" || target.Host == "" { + return fmt.Errorf("upstream URL must include scheme and host") + } + + limits, err := upstream.NewRateLimitersRegistry(ctx, cfg.RateLimiters, &logger) + if err != nil { + return fmt.Errorf("failed to initialize rate limiters: %w", err) + } + + projects := make(map[string]*auth.AuthRegistry, len(cfg.Projects)) + for _, project := range cfg.Projects { + if project == nil || project.Auth == nil { + continue + } + registry, err := auth.NewAuthRegistry(ctx, &logger, project.Id, project.Auth, limits) + if err != nil { + return fmt.Errorf("failed to initialize auth for project %s: %w", project.Id, err) + } + projects[project.Id] = registry + } + + ap := &authProxy{ + proxy: httputil.NewSingleHostReverseProxy(target), + projects: projects, + } + + addr := "0.0.0.0:4000" + if cfg.Server != nil && cfg.Server.HttpHostV4 != nil && cfg.Server.HttpPortV4 != nil { + addr = fmt.Sprintf("%s:%d", *cfg.Server.HttpHostV4, *cfg.Server.HttpPortV4) + } + + readTimeout := 30 * time.Second + writeTimeout := 120 * time.Second + if cfg.Server != nil { + if cfg.Server.ReadTimeout != nil { + readTimeout = cfg.Server.ReadTimeout.Duration() + } + if cfg.Server.WriteTimeout != nil { + writeTimeout = cfg.Server.WriteTimeout.Duration() + } + } + server := &http.Server{ + Addr: addr, + Handler: ap, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: 300 * time.Second, + MaxHeaderBytes: 1 << 20, + } + errCh := make(chan error, 1) + go func() { + logger.Info().Str("addr", addr).Str("upstream", target.Redacted()).Msg("starting auth proxy") + errCh <- server.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownTimeout := 10 * time.Second + if cfg.Server != nil && cfg.Server.WaitAfterShutdown != nil { + shutdownTimeout = cfg.Server.WaitAfterShutdown.Duration() + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return err + } + return nil + case err := <-errCh: + if err == http.ErrServerClosed { + return nil + } + return err + } +} + +func (p *authProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && (r.URL.Path == "/" || r.URL.Path == "/healthcheck" || strings.HasSuffix(r.URL.Path, "/healthcheck")) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + return + } + if r.Method == http.MethodOptions { + p.proxy.ServeHTTP(w, r) + return + } + if r.Method != http.MethodPost { + writeProxyError(w, nil, common.NewErrInvalidRequest(fmt.Errorf("method not allowed"))) + return + } + + projectID, err := projectFromPath(r.URL.Path) + if err != nil { + writeProxyError(w, nil, common.NewErrInvalidUrlPath(err.Error(), r.URL.Path)) + return + } + registry := p.projects[projectID] + if registry == nil { + writeProxyError(w, nil, common.NewErrProjectNotFound(projectID)) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + writeProxyError(w, nil, common.NewErrInvalidRequest(err)) + return + } + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(body)) + r.ContentLength = int64(len(body)) + + methods, err := requestMethods(body) + if err != nil { + writeProxyError(w, nil, common.NewErrJsonRpcRequestUnmarshal(err, body)) + return + } + + clientIP := clientIPFromRemoteAddr(r.RemoteAddr) + for _, methodBody := range methods { + req := common.NewNormalizedRequest(methodBody.raw) + req.SetClientIP(clientIP) + method, err := req.Method() + if err != nil { + writeProxyError(w, req, common.NewErrJsonRpcRequestUnmarshal(err, methodBody.raw)) + return + } + payload, err := auth.NewPayloadFromHttp(method, clientIP, r.Header, r.URL.Query()) + if err != nil { + writeProxyError(w, req, common.NewErrInvalidRequest(err)) + return + } + if _, err := registry.Authenticate(r.Context(), req, method, payload); err != nil { + writeProxyError(w, req, err) + return + } + } + + p.proxy.ServeHTTP(w, r) +} + +type rpcMethodBody struct { + raw []byte +} + +func requestMethods(body []byte) ([]rpcMethodBody, error) { + var batch []json.RawMessage + if err := json.Unmarshal(body, &batch); err == nil { + if len(batch) == 0 { + return nil, fmt.Errorf("empty JSON-RPC batch") + } + methods := make([]rpcMethodBody, 0, len(batch)) + for _, raw := range batch { + methods = append(methods, rpcMethodBody{raw: raw}) + } + return methods, nil + } + + var single map[string]json.RawMessage + if err := json.Unmarshal(body, &single); err != nil { + return nil, fmt.Errorf("invalid JSON-RPC request") + } + return []rpcMethodBody{{raw: body}}, nil +} + +func projectFromPath(rawPath string) (string, error) { + clean := path.Clean(rawPath) + if clean == "." || clean == "/" { + return "", fmt.Errorf("project is required in path") + } + parts := strings.Split(strings.TrimPrefix(clean, "/"), "/") + if parts[0] == "" { + return "", fmt.Errorf("project is required in path") + } + return parts[0], nil +} + +func clientIPFromRemoteAddr(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err == nil { + return host + } + if ip := net.ParseIP(remoteAddr); ip != nil { + return ip.String() + } + if strings.HasPrefix(remoteAddr, "[") { + if end := strings.Index(remoteAddr, "]"); end > 0 { + if ip := net.ParseIP(remoteAddr[1:end]); ip != nil { + return ip.String() + } + } + } + return remoteAddr +} + +func writeProxyError(w http.ResponseWriter, req *common.NormalizedRequest, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(proxyErrorStatus(err)) + + translated := common.TranslateToJsonRpcException(err) + code := common.JsonRpcErrorServerSideException + message := err.Error() + jre := &common.ErrJsonRpcExceptionInternal{} + if errors.As(translated, &jre) { + code = jre.NormalizedCode() + message = jre.Message + } + + var id interface{} + if req != nil { + if jrr, _ := req.JsonRpcRequest(); jrr != nil { + id = jrr.ID + } + } + body, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]interface{}{ + "code": code, + "message": message, + }, + }) + _, _ = w.Write(body) +} + +func proxyErrorStatus(err error) int { + switch { + case common.HasErrorCode(err, common.ErrCodeInvalidUrlPath, common.ErrCodeJsonRpcRequestUnmarshal, common.ErrCodeInvalidRequest): + return http.StatusBadRequest + case common.HasErrorCode(err, common.ErrCodeAuthUnauthorized, common.ErrCodeEndpointUnauthorized): + return http.StatusUnauthorized + case common.HasErrorCode(err, common.ErrCodeProjectNotFound, common.ErrCodeNetworkNotFound, common.ErrCodeNetworkNotSupported): + return http.StatusNotFound + case common.HasErrorCode(err, common.ErrCodeEndpointRequestTooLarge): + return http.StatusRequestEntityTooLarge + case common.HasErrorCode( + err, + common.ErrCodeAuthRateLimitRuleExceeded, + common.ErrCodeProjectRateLimitRuleExceeded, + common.ErrCodeNetworkRateLimitRuleExceeded, + common.ErrCodeEndpointCapacityExceeded, + ): + return http.StatusTooManyRequests + default: + return http.StatusOK + } +} diff --git a/cmd/erpc/auth_proxy_test.go b/cmd/erpc/auth_proxy_test.go new file mode 100644 index 000000000..05d2dfa95 --- /dev/null +++ b/cmd/erpc/auth_proxy_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strings" + "testing" + + "github.com/erpc/erpc/auth" + "github.com/erpc/erpc/common" + "github.com/erpc/erpc/upstream" + "github.com/rs/zerolog" +) + +func TestAuthProxySecretAuthAndRateLimit(t *testing.T) { + var forwarded int + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + forwarded++ + if r.URL.Path != "/cache/evm/1" { + t.Fatalf("unexpected forwarded path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + })) + defer backend.Close() + + proxy := newTestAuthProxy(t, backend.URL, &common.AuthConfig{Strategies: []*common.AuthStrategyConfig{ + { + Type: common.AuthTypeSecret, + Secret: &common.SecretStrategyConfig{Id: "tester", Value: "good"}, + RateLimitBudget: "one", + }, + }}, &common.RateLimiterConfig{ + Store: &common.RateLimitStoreConfig{Driver: "memory"}, + Budgets: []*common.RateLimitBudgetConfig{{ + Id: "one", + Rules: []*common.RateLimitRuleConfig{{ + Method: "*", + MaxCount: 1, + Period: common.RateLimitPeriodMinute, + PerUser: true, + }}, + }}, + }) + + first := httptest.NewRecorder() + proxy.ServeHTTP(first, rpcRequest("/cache/evm/1?secret=good")) + if first.Code != http.StatusOK || !strings.Contains(first.Body.String(), `"result":"0x1"`) { + t.Fatalf("expected valid request to forward, code=%d body=%s", first.Code, first.Body.String()) + } + + bad := httptest.NewRecorder() + proxy.ServeHTTP(bad, rpcRequest("/cache/evm/1?secret=bad")) + if bad.Code != http.StatusUnauthorized || !strings.Contains(bad.Body.String(), "unauthorized") { + t.Fatalf("expected bad secret to be rejected, code=%d body=%s", bad.Code, bad.Body.String()) + } + + limited := httptest.NewRecorder() + proxy.ServeHTTP(limited, rpcRequest("/cache/evm/1?secret=good")) + if limited.Code != http.StatusTooManyRequests || !strings.Contains(limited.Body.String(), "rate-limit exceeded") { + t.Fatalf("expected second valid request to be rate-limited, code=%d body=%s", limited.Code, limited.Body.String()) + } + if forwarded != 1 { + t.Fatalf("expected only one forwarded request, got %d", forwarded) + } +} + +func TestAuthProxyNormalizesClientIPForNetworkAuth(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + })) + defer backend.Close() + + proxy := newTestAuthProxy(t, backend.URL, &common.AuthConfig{Strategies: []*common.AuthStrategyConfig{ + { + Type: common.AuthTypeNetwork, + Network: &common.NetworkStrategyConfig{AllowedIPs: []string{"192.0.2.1"}}, + }, + }}, nil) + + resp := httptest.NewRecorder() + proxy.ServeHTTP(resp, rpcRequest("/cache/evm/1")) + if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `"result":"0x1"`) { + t.Fatalf("expected network auth to use host without port, code=%d body=%s", resp.Code, resp.Body.String()) + } +} + +func newTestAuthProxy(t *testing.T, upstreamURL string, authCfg *common.AuthConfig, rateLimiters *common.RateLimiterConfig) *authProxy { + t.Helper() + + logger := zerolog.New(io.Discard) + limits, err := upstream.NewRateLimitersRegistry(context.Background(), rateLimiters, &logger) + if err != nil { + t.Fatal(err) + } + registry, err := auth.NewAuthRegistry(context.Background(), &logger, "cache", authCfg, limits) + if err != nil { + t.Fatal(err) + } + target, err := url.Parse(upstreamURL) + if err != nil { + t.Fatal(err) + } + return &authProxy{ + proxy: httputil.NewSingleHostReverseProxy(target), + projects: map[string]*auth.AuthRegistry{ + "cache": registry, + }, + } +} + +func rpcRequest(path string) *http.Request { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}`)) + req.RemoteAddr = "192.0.2.1:12345" + return req +} diff --git a/cmd/erpc/main.go b/cmd/erpc/main.go index 96c45a1bb..598bcb36a 100644 --- a/cmd/erpc/main.go +++ b/cmd/erpc/main.go @@ -79,6 +79,11 @@ func main() { Name: "require-config", Usage: "Enforce passing a config file instead of using a default project and public endpoints", } + upstreamFlag := &cli.StringFlag{ + Name: "upstream", + Usage: "Upstream URL to reverse-proxy after auth succeeds", + Required: true, + } // Define the validate command validateCmd := &cli.Command{ @@ -146,6 +151,29 @@ func main() { }), } + authProxyCmd := &cli.Command{ + Name: "auth-proxy", + Usage: "Start a lightweight auth-checking reverse proxy", + Flags: []cli.Flag{ + configFileFlag, + upstreamFlag, + requireConfigFlag, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + logger.Info(). + Str("action", cmd.Name). + Str("version", common.ErpcVersion). + Str("commit", common.ErpcCommitSha). + Msg("executing command") + cfg, err := getConfig(logger, cmd) + if err != nil { + logger.Error().Err(err).Msg("failed to load configuration") + return err + } + return runAuthProxy(ctx, logger, cfg, cmd.String("upstream")) + }, + } + // Define the main command cmd := &cli.Command{ Name: "erpc", @@ -170,6 +198,7 @@ func main() { Commands: []*cli.Command{ startCmd, validateCmd, + authProxyCmd, }, } if err := cmd.Run(ctx, os.Args); err != nil {