Skip to content

Commit 3ee09b9

Browse files
Add support for VM HTTP2 handlers (#3294)
Signed-off-by: Joshua Kim <[email protected]> Co-authored-by: Stephen Buttolph <[email protected]>
1 parent 9719254 commit 3ee09b9

File tree

24 files changed

+1746
-626
lines changed

24 files changed

+1746
-626
lines changed

api/grpcclient/client.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
2+
// See the file LICENSE for licensing terms.
3+
4+
package grpcclient
5+
6+
import (
7+
"context"
8+
"fmt"
9+
10+
"google.golang.org/grpc"
11+
12+
"github.com/ava-labs/avalanchego/ids"
13+
)
14+
15+
// NewChainClient returns grpc.ClientConn that prefixes method calls with the
16+
// provided chainID prefix
17+
func NewChainClient(uri string, chainID ids.ID, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
18+
dialOpts := []grpc.DialOption{
19+
grpc.WithUnaryInterceptor(PrefixChainIDUnaryClientInterceptor(chainID)),
20+
grpc.WithStreamInterceptor(PrefixChainIDStreamClientInterceptor(chainID)),
21+
}
22+
23+
dialOpts = append(dialOpts, opts...)
24+
25+
conn, err := grpc.NewClient(uri, dialOpts...)
26+
if err != nil {
27+
return nil, fmt.Errorf("failed to initialize chain grpc client: %w", err)
28+
}
29+
30+
return conn, nil
31+
}
32+
33+
// PrefixChainIDUnaryClientInterceptor prefixes unary grpc calls with the
34+
// provided chainID prefix
35+
func PrefixChainIDUnaryClientInterceptor(chainID ids.ID) grpc.UnaryClientInterceptor {
36+
return func(
37+
ctx context.Context,
38+
method string,
39+
req any,
40+
reply any,
41+
cc *grpc.ClientConn,
42+
invoker grpc.UnaryInvoker,
43+
opts ...grpc.CallOption,
44+
) error {
45+
return invoker(ctx, prefix(chainID, method), req, reply, cc, opts...)
46+
}
47+
}
48+
49+
// PrefixChainIDStreamClientInterceptor prefixes streaming grpc calls with the
50+
// provided chainID prefix
51+
func PrefixChainIDStreamClientInterceptor(chainID ids.ID) grpc.StreamClientInterceptor {
52+
return func(
53+
ctx context.Context,
54+
desc *grpc.StreamDesc,
55+
cc *grpc.ClientConn,
56+
method string,
57+
streamer grpc.Streamer,
58+
opts ...grpc.CallOption,
59+
) (grpc.ClientStream, error) {
60+
return streamer(ctx, desc, cc, prefix(chainID, method), opts...)
61+
}
62+
}
63+
64+
// http/2 :path takes the form of /ChainID/Service/Method
65+
func prefix(chainID ids.ID, method string) string {
66+
return "/" + chainID.String() + method
67+
}

api/server/http2_router.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
2+
// See the file LICENSE for licensing terms.
3+
4+
package server
5+
6+
import (
7+
"net/http"
8+
"strings"
9+
"sync"
10+
11+
"github.com/ava-labs/avalanchego/ids"
12+
)
13+
14+
var _ http.Handler = (*http2Router)(nil)
15+
16+
type http2Router struct {
17+
lock sync.RWMutex
18+
handlers map[string]http.Handler
19+
}
20+
21+
func newHTTP2Router() *http2Router {
22+
return &http2Router{
23+
handlers: make(map[string]http.Handler),
24+
}
25+
}
26+
27+
func (h *http2Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
28+
// The :path pseudo-header takes the form of /Prefix/Path
29+
parsed := strings.Split(r.URL.Path, "/")
30+
if len(parsed) < 2 {
31+
w.WriteHeader(http.StatusBadRequest)
32+
return
33+
}
34+
35+
chainID := parsed[1]
36+
37+
h.lock.RLock()
38+
handler, ok := h.handlers[chainID]
39+
h.lock.RUnlock()
40+
if !ok {
41+
w.WriteHeader(http.StatusNotFound)
42+
return
43+
}
44+
45+
// Deep copy the request to avoid weird behavior from modifying r
46+
requestDeepCopy := r.Clone(r.Context())
47+
// Route this request to the http2 handler using the chain prefix
48+
requestDeepCopy.URL.Path = strings.TrimPrefix(
49+
requestDeepCopy.URL.Path,
50+
"/"+chainID,
51+
)
52+
53+
handler.ServeHTTP(w, requestDeepCopy)
54+
}
55+
56+
func (h *http2Router) Add(chainID ids.ID, handler http.Handler) bool {
57+
h.lock.Lock()
58+
defer h.lock.Unlock()
59+
60+
chainIDStr := chainID.String()
61+
if _, ok := h.handlers[chainIDStr]; ok {
62+
return false
63+
}
64+
65+
h.handlers[chainIDStr] = handler
66+
return true
67+
}

api/server/http2_router_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved.
2+
// See the file LICENSE for licensing terms.
3+
4+
package server
5+
6+
import (
7+
"fmt"
8+
"net/http"
9+
"net/http/httptest"
10+
"net/url"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/ava-labs/avalanchego/ids"
16+
)
17+
18+
func TestHTTP2RouterAdd(t *testing.T) {
19+
require := require.New(t)
20+
h := newHTTP2Router()
21+
handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
22+
23+
require.True(h.Add(ids.Empty, handler))
24+
require.False(h.Add(ids.Empty, handler))
25+
}
26+
27+
func TestHTTP2RouterServeHTTP(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
chainIDs []ids.ID
31+
path string
32+
wantCode int
33+
}{
34+
{
35+
name: "invalid request",
36+
path: "foo",
37+
wantCode: http.StatusBadRequest,
38+
},
39+
{
40+
name: "invalid handler",
41+
path: "/foo/bar/method",
42+
wantCode: http.StatusNotFound,
43+
},
44+
{
45+
name: "valid handler",
46+
chainIDs: []ids.ID{{'f', 'o', 'o'}},
47+
path: fmt.Sprintf("/%s/bar/method", ids.ID{'f', 'o', 'o'}.String()),
48+
wantCode: http.StatusOK,
49+
},
50+
}
51+
52+
for _, tt := range tests {
53+
t.Run(tt.name, func(t *testing.T) {
54+
require := require.New(t)
55+
56+
h := newHTTP2Router()
57+
handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
58+
writer := httptest.NewRecorder()
59+
request := httptest.NewRequest(http.MethodPost, "/", nil)
60+
request.URL = &url.URL{
61+
Path: tt.path,
62+
}
63+
64+
for _, chainID := range tt.chainIDs {
65+
require.True(h.Add(chainID, handler))
66+
}
67+
68+
h.ServeHTTP(writer, request)
69+
require.Equal(tt.wantCode, writer.Code)
70+
})
71+
}
72+
}

api/server/server.go

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/rs/cors"
1818
"go.uber.org/zap"
1919
"golang.org/x/net/http2"
20+
"golang.org/x/net/http2/h2c"
2021

2122
"github.com/ava-labs/avalanchego/api"
2223
"github.com/ava-labs/avalanchego/ids"
@@ -88,7 +89,8 @@ type server struct {
8889
metrics *metrics
8990

9091
// Maps endpoints to handlers
91-
router *router
92+
router *router
93+
http2Router *http2Router
9294

9395
srv *http.Server
9496

@@ -115,33 +117,30 @@ func New(
115117
}
116118

117119
router := newRouter()
118-
allowedHostsHandler := filterInvalidHosts(router, allowedHosts)
119-
corsHandler := cors.New(cors.Options{
120-
AllowedOrigins: allowedOrigins,
121-
AllowCredentials: true,
122-
}).Handler(allowedHostsHandler)
123-
gzipHandler := gziphandler.GzipHandler(corsHandler)
124-
var handler http.Handler = http.HandlerFunc(
125-
func(w http.ResponseWriter, r *http.Request) {
126-
// Attach this node's ID as a header
127-
w.Header().Set("node-id", nodeID.String())
128-
gzipHandler.ServeHTTP(w, r)
129-
},
130-
)
120+
handler := wrapHandler(router, nodeID, allowedOrigins, allowedHosts, true)
121+
122+
http2Router := newHTTP2Router()
123+
// Do not use gzip middleware because it breaks the grpc spec
124+
http2Handler := wrapHandler(http2Router, nodeID, allowedOrigins, allowedHosts, false)
131125

132126
httpServer := &http.Server{
133-
Handler: handler,
127+
Handler: h2c.NewHandler(
128+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129+
if r.ProtoMajor == 2 {
130+
http2Handler.ServeHTTP(w, r)
131+
return
132+
}
133+
134+
handler.ServeHTTP(w, r)
135+
}),
136+
&http2.Server{
137+
MaxConcurrentStreams: maxConcurrentStreams,
138+
}),
134139
ReadTimeout: httpConfig.ReadTimeout,
135140
ReadHeaderTimeout: httpConfig.ReadHeaderTimeout,
136141
WriteTimeout: httpConfig.WriteTimeout,
137142
IdleTimeout: httpConfig.IdleTimeout,
138143
}
139-
err = http2.ConfigureServer(httpServer, &http2.Server{
140-
MaxConcurrentStreams: maxConcurrentStreams,
141-
})
142-
if err != nil {
143-
return nil, err
144-
}
145144

146145
log.Info("API created",
147146
zap.Strings("allowedOrigins", allowedOrigins),
@@ -154,6 +153,7 @@ func New(
154153
tracer: tracer,
155154
metrics: m,
156155
router: router,
156+
http2Router: http2Router,
157157
srv: httpServer,
158158
listener: listener,
159159
}, nil
@@ -199,6 +199,30 @@ func (s *server) RegisterChain(chainName string, ctx *snow.ConsensusContext, vm
199199
)
200200
}
201201
}
202+
203+
ctx.Lock.Lock()
204+
http2Handler, err := vm.CreateHTTP2Handler(context.TODO())
205+
ctx.Lock.Unlock()
206+
if err != nil {
207+
s.log.Error("failed to create http2 handler",
208+
zap.String("chainName", chainName),
209+
zap.Error(err),
210+
)
211+
return
212+
}
213+
214+
if http2Handler == nil {
215+
return
216+
}
217+
218+
http2Handler = s.wrapMiddleware(chainName, http2Handler, ctx)
219+
if !s.http2Router.Add(ctx.ChainID, http2Handler) {
220+
s.log.Error(
221+
"failed to add route to http2 handler",
222+
zap.String("chainName", chainName),
223+
zap.Error(err),
224+
)
225+
}
202226
}
203227

204228
func (s *server) addChainRoute(chainName string, handler http.Handler, ctx *snow.ConsensusContext, base, endpoint string) error {
@@ -207,13 +231,17 @@ func (s *server) addChainRoute(chainName string, handler http.Handler, ctx *snow
207231
zap.String("url", url),
208232
zap.String("endpoint", endpoint),
209233
)
234+
handler = s.wrapMiddleware(chainName, handler, ctx)
235+
return s.router.AddRouter(url, endpoint, handler)
236+
}
237+
238+
func (s *server) wrapMiddleware(chainName string, handler http.Handler, ctx *snow.ConsensusContext) http.Handler {
210239
if s.tracingEnabled {
211240
handler = api.TraceHandler(handler, chainName, s.tracer)
212241
}
213242
// Apply middleware to reject calls to the handler before the chain finishes bootstrapping
214243
handler = rejectMiddleware(handler, ctx)
215-
handler = s.metrics.wrapHandler(chainName, handler)
216-
return s.router.AddRouter(url, endpoint, handler)
244+
return s.metrics.wrapHandler(chainName, handler)
217245
}
218246

219247
func (s *server) AddRoute(handler http.Handler, base, endpoint string) error {
@@ -299,3 +327,27 @@ func (a readPathAdder) AddRoute(handler http.Handler, base, endpoint string) err
299327
func (a readPathAdder) AddAliases(endpoint string, aliases ...string) error {
300328
return a.pather.AddAliasesWithReadLock(endpoint, aliases...)
301329
}
330+
331+
func wrapHandler(
332+
handler http.Handler,
333+
nodeID ids.NodeID,
334+
allowedOrigins []string,
335+
allowedHosts []string,
336+
gzip bool,
337+
) http.Handler {
338+
h := filterInvalidHosts(handler, allowedHosts)
339+
h = cors.New(cors.Options{
340+
AllowedOrigins: allowedOrigins,
341+
AllowCredentials: true,
342+
}).Handler(h)
343+
if gzip {
344+
h = gziphandler.GzipHandler(h)
345+
}
346+
return http.HandlerFunc(
347+
func(w http.ResponseWriter, r *http.Request) {
348+
// Attach this node's ID as a header
349+
w.Header().Set("node-id", nodeID.String())
350+
h.ServeHTTP(w, r)
351+
},
352+
)
353+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ require (
1414
github.com/NYTimes/gziphandler v1.1.1
1515
github.com/StephenButtolph/canoto v0.15.0
1616
github.com/antithesishq/antithesis-sdk-go v0.3.8
17-
github.com/ava-labs/coreth v0.15.1-rc.0.0.20250530184801-28421010abae
17+
github.com/ava-labs/coreth v0.15.2-rc.0
1818
github.com/ava-labs/ledger-avalanche/go v0.0.0-20241009183145-e6f90a8a1a60
1919
github.com/ava-labs/libevm v1.13.14-0.2.0.release
2020
github.com/btcsuite/btcd/btcutil v1.1.3

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ github.com/antithesishq/antithesis-sdk-go v0.3.8/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl
6868
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
6969
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
7070
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
71-
github.com/ava-labs/coreth v0.15.1-rc.0.0.20250530184801-28421010abae h1:PPcOEtY3SoXruzMPYJMxoeoExRVhf5UOiH7SvdPHQwY=
72-
github.com/ava-labs/coreth v0.15.1-rc.0.0.20250530184801-28421010abae/go.mod h1:Tl6TfpOwSq0bXvN7DTENXB4As3hml8ma9OQEWS9DH9I=
71+
github.com/ava-labs/coreth v0.15.2-rc.0 h1:RnCH59A/WMEdMyuqugaPIk1xrQQuo9vEgMVOjk9ZjWQ=
72+
github.com/ava-labs/coreth v0.15.2-rc.0/go.mod h1:Tl6TfpOwSq0bXvN7DTENXB4As3hml8ma9OQEWS9DH9I=
7373
github.com/ava-labs/ledger-avalanche/go v0.0.0-20241009183145-e6f90a8a1a60 h1:EL66gtXOAwR/4KYBjOV03LTWgkEXvLePribLlJNu4g0=
7474
github.com/ava-labs/ledger-avalanche/go v0.0.0-20241009183145-e6f90a8a1a60/go.mod h1:/7qKobTfbzBu7eSTVaXMTr56yTYk4j2Px6/8G+idxHo=
7575
github.com/ava-labs/libevm v1.13.14-0.2.0.release h1:uKGCc5/ceeBbfAPRVtBUxbQt50WzB2pEDb8Uy93ePgQ=

0 commit comments

Comments
 (0)