generated from ethereum-optimism/.github
-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
op-txproxy: external validating proxy for conditional transactions (#42)
* txpool svc * change mod github path * tag-tool * codeowners
- Loading branch information
1 parent
88521be
commit dff24e9
Showing
17 changed files
with
1,205 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
* @ethereum-optimism/infra-reviewers | ||
|
||
/op-txproxy @ethereum-optimism/devxpod |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
bin |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
FROM golang:1.21.3-alpine3.18 as builder | ||
|
||
COPY ./op-txproxy /app | ||
|
||
WORKDIR /app | ||
RUN apk --no-cache add make jq bash git alpine-sdk | ||
RUN make build | ||
|
||
FROM alpine:3.18 | ||
RUN apk --no-cache add ca-certificates | ||
|
||
RUN addgroup -S app && adduser -S app -G app | ||
USER app | ||
WORKDIR /app | ||
|
||
COPY --from=builder /app/bin/op-txproxy /app | ||
|
||
ENTRYPOINT ["/app/op-txproxy"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
GIT_COMMIT := $(shell git rev-parse HEAD) | ||
GIT_DATE := $(shell git show -s --format='%ct') | ||
|
||
LDFLAGSSTRING +=-X main.GitCommit=$(GIT_COMMIT) | ||
LDFLAGSSTRING +=-X main.GitDate=$(GITDGIT_DATEATE) | ||
LDFLAGSSTRING +=-X main.Version=$(OP_CONDUCTOR_MON_VERSION) | ||
LDFLAGS := -ldflags "$(LDFLAGSSTRING)" | ||
|
||
|
||
all: build | ||
|
||
build: | ||
env GO111MODULE=on go build -v $(LDFLAGS) -o ./bin/op-txproxy ./cmd | ||
|
||
clean: | ||
rm ./bin/op-txproxy | ||
|
||
test: | ||
go test -v ./... | ||
|
||
lint: | ||
golangci-lint run ./... | ||
|
||
.PHONY: \ | ||
build \ | ||
clean \ | ||
test \ | ||
lint |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
# op-txproxy | ||
|
||
A supplemental passthrough proxy for some execution engine endpoints. This proxy does not forward all rpc traffic and only exposes a specific set of endpoints. | ||
Operationally, the public ingress proxy should only re-route requests for these endpoints. | ||
|
||
```mermaid | ||
stateDiagram-v2 | ||
proxyd --> txproxy: intercepted methods | ||
proxyd --> backend: unintercepted methods | ||
txproxy --> backend | ||
``` | ||
|
||
## Setup | ||
Install go 1.21 | ||
``` | ||
make build | ||
./bin/op-txproxy --help | ||
``` | ||
|
||
## Endpoints | ||
|
||
### eth_sendRawTransactionConditional | ||
|
||
An outcome of how to integrate this [spec](https://notes.ethereum.org/@yoav/SkaX2lS9j) safely for permissionless 4337 bundler participation. This solution in the design doc [proposal](https://github.com/ethereum-optimism/design-docs/blob/main/ecosystem/sendRawTransactionConditional/proposal.md) | ||
requires a validating proxy that can be horizontally scaled and pre-emptively reject invalid conditional transaction. The implemented endpoint covers | ||
these objectives: | ||
1. **Auth**. preemptively put in place to enable a variety of auth policies (allowlist, rate limits, etc). | ||
|
||
The caller authenticates themselves with any valid ECDSA-secp256k1 key, like an Ethereum key. The computed signature is over the [EIP-191](https://eips.ethereum.org/EIPS/eip-191) hash of the request body (up to the 5MB request body limit). | ||
|
||
With the signature and signing address, the request is authenticated via the `X-Optimism-Signature` header of the request with the value `<public key address>: <signature>`. | ||
|
||
2. **Rate Limits**. global rate limits on the endpoint are applied here. | ||
2. **Rejection Switch**. this proxy can be rolled with a flag/env switch to reject conditional transaction without needing to interrupt the execution engine. | ||
3. **Basic Validation**. stateless validation is done in the endpoint to reject invalid conditional transactions and apply additional restricts on the usage (only 4337 entrypoint tx target support). | ||
4. **Metrics**. performance of this endpoint can be observed in order to inform adjustments to rate limits, shutoff, or auth policies to implement. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
package op_txproxy | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"io" | ||
"net/http" | ||
"strings" | ||
|
||
"github.com/ethereum/go-ethereum/accounts" | ||
"github.com/ethereum/go-ethereum/common" | ||
"github.com/ethereum/go-ethereum/crypto" | ||
) | ||
|
||
var ( | ||
defaultBodyLimit = 5 * 1024 * 1024 // default in op-geth | ||
|
||
DefaultAuthHeaderKey = "X-Optimism-Signature" | ||
) | ||
|
||
type authHandler struct { | ||
headerKey string | ||
next http.Handler | ||
} | ||
|
||
// This middleware detects when authentication information is present on the request. If | ||
// so, it will validate and set the caller in the request context. It does not reject | ||
// if authentication information is missing. It is up to the request handler to do so via | ||
// the missing `AuthContext` | ||
// - NOTE: only up to the default body limit (5MB) is read when constructing the text hash | ||
// that is signed over by the caller | ||
func AuthMiddleware(headerKey string) func(next http.Handler) http.Handler { | ||
return func(next http.Handler) http.Handler { | ||
return &authHandler{headerKey, next} | ||
} | ||
} | ||
|
||
type authContextKey struct{} | ||
|
||
type AuthContext struct { | ||
Caller common.Address | ||
} | ||
|
||
// ServeHTTP serves JSON-RPC requests over HTTP, implements http.Handler | ||
func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
authHeader := r.Header.Get(h.headerKey) | ||
if authHeader == "" { | ||
h.next.ServeHTTP(w, r) | ||
return | ||
} | ||
authElems := strings.Split(authHeader, ":") | ||
if len(authElems) != 2 { | ||
http.Error(w, "misformatted auth header", http.StatusBadRequest) | ||
return | ||
} | ||
|
||
if r.Body == nil { | ||
// edge case from unit tests | ||
r.Body = io.NopCloser(bytes.NewBuffer(nil)) | ||
} | ||
|
||
// Since this middleware runs prior to the server, we need to manually apply the body limit when reading. | ||
bodyBytes, err := io.ReadAll(io.LimitReader(r.Body, int64(defaultBodyLimit))) | ||
if err != nil { | ||
http.Error(w, "unable to parse request body", http.StatusInternalServerError) | ||
return | ||
} | ||
|
||
r.Body = struct { | ||
io.Reader | ||
io.Closer | ||
}{ | ||
io.MultiReader(bytes.NewReader(bodyBytes), r.Body), | ||
r.Body, | ||
} | ||
|
||
txtHash := accounts.TextHash(bodyBytes) | ||
caller, signature := common.HexToAddress(authElems[0]), common.FromHex(authElems[1]) | ||
sigPubKey, err := crypto.SigToPub(txtHash, signature) | ||
if err != nil { | ||
http.Error(w, "invalid authentication signature", http.StatusBadRequest) | ||
return | ||
} | ||
|
||
if caller != crypto.PubkeyToAddress(*sigPubKey) { | ||
http.Error(w, "mismatched recovered signer", http.StatusBadRequest) | ||
return | ||
} | ||
|
||
// Set the authenticated caller in the context | ||
newCtx := context.WithValue(r.Context(), authContextKey{}, &AuthContext{caller}) | ||
h.next.ServeHTTP(w, r.WithContext(newCtx)) | ||
} | ||
|
||
func AuthFromContext(ctx context.Context) *AuthContext { | ||
auth, ok := ctx.Value(authContextKey{}).(*AuthContext) | ||
if !ok { | ||
return nil | ||
} | ||
return auth | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
package op_txproxy | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/http/httptest" | ||
"strings" | ||
"testing" | ||
|
||
oprpc "github.com/ethereum-optimism/optimism/op-service/rpc" | ||
|
||
"github.com/ethereum/go-ethereum/accounts" | ||
"github.com/ethereum/go-ethereum/common" | ||
"github.com/ethereum/go-ethereum/crypto" | ||
"github.com/ethereum/go-ethereum/rpc" | ||
|
||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
var pingHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
fmt.Fprintf(w, "ping") | ||
}) | ||
|
||
func TestAuthHandlerMissingAuth(t *testing.T) { | ||
handler := authHandler{next: pingHandler} | ||
|
||
rr := httptest.NewRecorder() | ||
r, _ := http.NewRequest("GET", "/", nil) | ||
handler.ServeHTTP(rr, r) | ||
|
||
// simply forwards the request | ||
require.Equal(t, http.StatusOK, rr.Code) | ||
require.Equal(t, "ping", rr.Body.String()) | ||
} | ||
|
||
func TestAuthHandlerBadHeader(t *testing.T) { | ||
handler := authHandler{headerKey: "auth", next: pingHandler} | ||
|
||
rr := httptest.NewRecorder() | ||
r, _ := http.NewRequest("GET", "/", nil) | ||
r.Header.Set("auth", "foobarbaz") | ||
|
||
handler.ServeHTTP(rr, r) | ||
require.Equal(t, http.StatusBadRequest, rr.Code) | ||
} | ||
|
||
func TestAuthHandlerBadSignature(t *testing.T) { | ||
handler := authHandler{headerKey: "auth", next: pingHandler} | ||
|
||
rr := httptest.NewRecorder() | ||
r, _ := http.NewRequest("GET", "/", nil) | ||
r.Header.Set("auth", fmt.Sprintf("%s:%s", common.HexToAddress("0xa"), "foobar")) | ||
|
||
handler.ServeHTTP(rr, r) | ||
require.Equal(t, http.StatusBadRequest, rr.Code) | ||
} | ||
|
||
func TestAuthHandlerMismatchedCaller(t *testing.T) { | ||
handler := authHandler{headerKey: "auth", next: pingHandler} | ||
|
||
rr := httptest.NewRecorder() | ||
r, _ := http.NewRequest("GET", "/", strings.NewReader("body")) | ||
|
||
privKey, _ := crypto.GenerateKey() | ||
sig, _ := crypto.Sign(accounts.TextHash([]byte("body")), privKey) | ||
r.Header.Set("auth", fmt.Sprintf("%s:%s", common.HexToAddress("0xa"), sig)) | ||
|
||
handler.ServeHTTP(rr, r) | ||
require.Equal(t, http.StatusBadRequest, rr.Code) | ||
} | ||
|
||
func TestAuthHandlerSetContext(t *testing.T) { | ||
var ctx *AuthContext | ||
ctxHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
ctx = AuthFromContext(r.Context()) | ||
w.WriteHeader(http.StatusOK) | ||
}) | ||
|
||
handler := authHandler{headerKey: "auth", next: ctxHandler} | ||
|
||
rr := httptest.NewRecorder() | ||
body := bytes.NewBufferString("body") | ||
r, _ := http.NewRequest("GET", "/", body) | ||
|
||
privKey, _ := crypto.GenerateKey() | ||
sig, _ := crypto.Sign(accounts.TextHash(body.Bytes()), privKey) | ||
addr := crypto.PubkeyToAddress(privKey.PublicKey) | ||
r.Header.Set("auth", fmt.Sprintf("%s:%s", addr, common.Bytes2Hex(sig))) | ||
|
||
handler.ServeHTTP(rr, r) | ||
require.Equal(t, http.StatusOK, rr.Code) | ||
|
||
require.NotNil(t, ctx) | ||
require.Equal(t, addr, ctx.Caller) | ||
} | ||
|
||
func TestAuthHandlerRpcMiddleware(t *testing.T) { | ||
rpcServer := oprpc.NewServer("127.0.0.1", 0, "", oprpc.WithMiddleware(AuthMiddleware("auth"))) | ||
require.NoError(t, rpcServer.Start()) | ||
t.Cleanup(func() { _ = rpcServer.Stop() }) | ||
|
||
url := fmt.Sprintf("http://%s", rpcServer.Endpoint()) | ||
clnt, err := rpc.Dial(url) | ||
require.NoError(t, err) | ||
defer clnt.Close() | ||
|
||
// pass without auth (default handler does not deny) | ||
err = clnt.CallContext(context.Background(), nil, "rpc_modules") | ||
require.Nil(t, err) | ||
|
||
// denied with bad auth header | ||
clnt.SetHeader("auth", "foobar") | ||
err = clnt.CallContext(context.Background(), nil, "rpc_modules") | ||
require.NotNil(t, err) | ||
} | ||
|
||
func TestAuthHandlerRequestBodyLimit(t *testing.T) { | ||
var body []byte | ||
bodyHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
body, _ = io.ReadAll(r.Body) | ||
w.WriteHeader(http.StatusOK) | ||
}) | ||
|
||
handler := authHandler{headerKey: "auth", next: bodyHandler} | ||
|
||
// only up to limit is read when validating the request body | ||
authBody := strings.Repeat("*", defaultBodyLimit) | ||
excess := strings.Repeat("-", 10) | ||
|
||
rr := httptest.NewRecorder() | ||
r, _ := http.NewRequest("GET", "/", strings.NewReader(authBody+excess)) | ||
|
||
// sign over just the auth body | ||
privKey, _ := crypto.GenerateKey() | ||
sig, _ := crypto.Sign(accounts.TextHash([]byte(authBody)), privKey) | ||
addr := crypto.PubkeyToAddress(privKey.PublicKey) | ||
r.Header.Set("auth", fmt.Sprintf("%s:%s", addr, common.Bytes2Hex(sig))) | ||
|
||
// Auth handler successfully only parses through the max body limit | ||
handler.ServeHTTP(rr, r) | ||
require.Equal(t, http.StatusOK, rr.Code, rr.Body) | ||
|
||
// The next handler has the full request body present | ||
require.Len(t, body, len(authBody)+len(excess)) | ||
} |
Oops, something went wrong.