prysm-pulse/testing/middleware/engine-api-proxy/proxy.go
terence 5a66807989
Update to V5 (#13622)
* First take at updating everything to v5

* Patch gRPC gateway to use prysm v5

Fix patch

* Update go ssz

---------

Co-authored-by: Preston Van Loon <pvanloon@offchainlabs.com>
2024-02-15 05:46:47 +00:00

296 lines
8.6 KiB
Go

// Package proxy provides a proxy middleware for engine API requests between Ethereum
// consensus clients and execution clients accordingly. Allows for customizing
// in-flight requests or responses using custom triggers. Useful for end-to-end testing.
package proxy
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/prysmaticlabs/prysm/v5/network"
"github.com/sirupsen/logrus"
)
var (
defaultProxyHost = "127.0.0.1"
defaultProxyPort = 8545
)
type jsonRPCObject struct {
Jsonrpc string `json:"jsonrpc"`
Method string `json:"method"`
Params []interface{} `json:"params"`
ID uint64 `json:"id"`
Result interface{} `json:"result"`
}
type interceptorConfig struct {
responseGen func() interface{}
trigger func() bool
}
// Proxy server that sits as a middleware between an Ethereum consensus client and an execution client,
// allowing us to modify in-flight requests and responses for testing purposes.
type Proxy struct {
cfg *config
address string
srv *http.Server
lock sync.RWMutex
interceptors map[string]*interceptorConfig
backedUpRequests map[string][]*http.Request
}
// New creates a proxy server forwarding requests from a consensus client to an execution client.
func New(opts ...Option) (*Proxy, error) {
p := &Proxy{
cfg: &config{
proxyHost: defaultProxyHost,
proxyPort: defaultProxyPort,
logger: logrus.New(),
},
interceptors: make(map[string]*interceptorConfig),
backedUpRequests: map[string][]*http.Request{},
}
for _, o := range opts {
if err := o(p); err != nil {
return nil, err
}
}
if p.cfg.destinationUrl == nil {
return nil, errors.New("must provide a destination address for request proxying")
}
mux := http.NewServeMux()
mux.Handle("/", p)
addr := fmt.Sprintf("%s:%d", p.cfg.proxyHost, p.cfg.proxyPort)
srv := &http.Server{
Handler: mux,
Addr: addr,
ReadHeaderTimeout: time.Second,
}
p.address = addr
p.srv = srv
return p, nil
}
// Address for the proxy server.
func (p *Proxy) Address() string {
return p.address
}
// Start a proxy server.
func (p *Proxy) Start(ctx context.Context) error {
p.srv.BaseContext = func(listener net.Listener) context.Context {
return ctx
}
p.cfg.logger.WithFields(logrus.Fields{
"forwardingAddress": p.cfg.destinationUrl.String(),
}).Infof("Engine proxy now listening on address %s", p.address)
go func() {
if err := p.srv.ListenAndServe(); err != nil {
p.cfg.logger.Error(err)
}
}()
for {
<-ctx.Done()
return p.srv.Shutdown(context.Background())
}
}
// ServeHTTP requests from a consensus client to an execution client, modifying in-flight requests
// and/or responses as desired. It also processes any backed-up requests.
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
requestBytes, err := parseRequestBytes(r)
if err != nil {
p.cfg.logger.WithError(err).Error("Could not parse request")
return
}
// Check if we need to intercept the request with a custom response.
hasIntercepted, err := p.interceptIfNeeded(requestBytes, w, r)
if err != nil {
p.cfg.logger.WithError(err).Error("Could not intercept request")
return
}
if hasIntercepted {
return
}
// If we are not intercepting the request, we proxy as normal.
p.proxyRequest(requestBytes, w, r)
}
// AddRequestInterceptor for a desired json-rpc method by specifying a custom response
// and a function that checks if the interceptor should be triggered.
func (p *Proxy) AddRequestInterceptor(rpcMethodName string, response func() interface{}, trigger func() bool) {
p.lock.Lock()
defer p.lock.Unlock()
p.cfg.logger.Infof("Adding in interceptor for method %s", rpcMethodName)
p.interceptors[rpcMethodName] = &interceptorConfig{
response,
trigger,
}
}
// RemoveRequestInterceptor removes the request interceptor for the provided method.
func (p *Proxy) RemoveRequestInterceptor(rpcMethodName string) {
p.lock.Lock()
defer p.lock.Unlock()
p.cfg.logger.Infof("Removing interceptor for method %s", rpcMethodName)
delete(p.interceptors, rpcMethodName)
}
// ReleaseBackedUpRequests releases backed up http requests which
// were previously ignored due to our interceptors.
func (p *Proxy) ReleaseBackedUpRequests(rpcMethodName string) {
p.lock.Lock()
defer p.lock.Unlock()
reqs := p.backedUpRequests[rpcMethodName]
for _, r := range reqs {
p.cfg.logger.Infof("Sending backed up request for method %s", rpcMethodName)
rBytes, err := parseRequestBytes(r)
if err != nil {
p.cfg.logger.Error(err)
continue
}
res, err := p.sendHttpRequest(r, rBytes)
if err != nil {
p.cfg.logger.Error(err)
continue
}
p.cfg.logger.Infof("Received response %s for backed up request for method %s", http.StatusText(res.StatusCode), rpcMethodName)
}
delete(p.backedUpRequests, rpcMethodName)
}
// Checks if there is a custom interceptor hook on the request, check if it can be
// triggered, and then write the custom response to the writer.
func (p *Proxy) interceptIfNeeded(requestBytes []byte, w http.ResponseWriter, r *http.Request) (hasIntercepted bool, err error) {
if !isEngineAPICall(requestBytes) {
return
}
var jreq *jsonRPCObject
jreq, err = unmarshalRPCObject(requestBytes)
if err != nil {
return
}
p.lock.RLock()
defer p.lock.RUnlock()
interceptor, shouldIntercept := p.interceptors[jreq.Method]
if !shouldIntercept {
return
}
if !interceptor.trigger() {
return
}
jResp := &jsonRPCObject{
Method: jreq.Method,
ID: jreq.ID,
Result: interceptor.responseGen(),
}
if err = json.NewEncoder(w).Encode(jResp); err != nil {
return
}
hasIntercepted = true
p.backedUpRequests[jreq.Method] = append(p.backedUpRequests[jreq.Method], r)
return
}
// Create a new proxy request to the execution client.
func (p *Proxy) proxyRequest(requestBytes []byte, w http.ResponseWriter, r *http.Request) {
jreq, err := unmarshalRPCObject(requestBytes)
if err != nil {
p.cfg.logger.WithError(err).Error("Could not unmarshal request")
// Continue and mark it as unknown.
jreq = &jsonRPCObject{Method: "unknown"}
}
p.cfg.logger.Infof("Forwarding %s request for method %s to %s", r.Method, jreq.Method, p.cfg.destinationUrl.String())
proxyRes, err := p.sendHttpRequest(r, requestBytes)
if err != nil {
p.cfg.logger.WithError(err).Error("Could create new request")
return
}
p.cfg.logger.Infof("Received response for %s request with method %s from %s", r.Method, jreq.Method, p.cfg.destinationUrl.String())
defer func() {
if err = proxyRes.Body.Close(); err != nil {
p.cfg.logger.WithError(err).Error("Could not do close proxy responseGen body")
}
}()
// Pipe the proxy responseGen to the original caller.
if _, err = io.Copy(w, proxyRes.Body); err != nil {
p.cfg.logger.WithError(err).Error("Could not copy proxy request body")
return
}
}
func (p *Proxy) sendHttpRequest(req *http.Request, requestBytes []byte) (*http.Response, error) {
proxyReq, err := http.NewRequest(req.Method, p.cfg.destinationUrl.String(), req.Body)
if err != nil {
p.cfg.logger.WithError(err).Error("Could not create new request")
return nil, err
}
// Set the modified request as the proxy request body.
proxyReq.Body = ioutil.NopCloser(bytes.NewBuffer(requestBytes))
// Required proxy headers for forwarding JSON-RPC requests to the execution client.
proxyReq.Header.Set("Host", req.Host)
proxyReq.Header.Set("X-Forwarded-For", req.RemoteAddr)
proxyReq.Header.Set("Content-Type", "application/json")
client := &http.Client{}
if p.cfg.secret != "" {
client = network.NewHttpClientWithSecret(p.cfg.secret, "")
}
proxyRes, err := client.Do(proxyReq)
if err != nil {
p.cfg.logger.WithError(err).Error("Could not forward request to destination server")
return nil, err
}
return proxyRes, nil
}
// Peek into the bytes of an HTTP request's body.
func parseRequestBytes(req *http.Request) ([]byte, error) {
requestBytes, err := ioutil.ReadAll(req.Body)
if err != nil {
return nil, err
}
if err = req.Body.Close(); err != nil {
return nil, err
}
req.Body = ioutil.NopCloser(bytes.NewBuffer(requestBytes))
return requestBytes, nil
}
// Checks whether the JSON-RPC request is for the Ethereum engine API.
func isEngineAPICall(reqBytes []byte) bool {
jsonRequest, err := unmarshalRPCObject(reqBytes)
if err != nil {
switch {
case strings.Contains(err.Error(), "cannot unmarshal array"):
return false
default:
return false
}
}
return strings.Contains(jsonRequest.Method, "engine_")
}
func unmarshalRPCObject(b []byte) (*jsonRPCObject, error) {
r := &jsonRPCObject{}
if err := json.Unmarshal(b, r); err != nil {
return nil, err
}
return r, nil
}