node, rpc: add JWT auth support in client (#24911)

This adds a generic mechanism for 'dial options' in the RPC client,
and also implements a specific dial option for the JWT authentication
mechanism used by the engine API. Some real tests for the server-side
authentication handling are also added.

Co-authored-by: Joshua Gutow <jgutow@optimism.io>
Co-authored-by: Felix Lange <fjl@twurst.com>
This commit is contained in:
protolambda
2022-09-02 17:40:41 +02:00
committed by GitHub
co-authored by Joshua Gutow Felix Lange
parent 7f2890a9be
commit 90711efb0a
11 changed files with 574 additions and 49 deletions
+36 -9
View File
@@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"net/url"
"os"
"reflect"
"strconv"
"sync/atomic"
@@ -99,7 +100,7 @@ type Client struct {
reqTimeout chan *requestOp // removes response IDs when call timeout expires
}
type reconnectFunc func(ctx context.Context) (ServerCodec, error)
type reconnectFunc func(context.Context) (ServerCodec, error)
type clientContextKey struct{}
@@ -153,14 +154,16 @@ func (op *requestOp) wait(ctx context.Context, c *Client) (*jsonrpcMessage, erro
//
// The currently supported URL schemes are "http", "https", "ws" and "wss". If rawurl is a
// file name with no URL scheme, a local socket connection is established using UNIX
// domain sockets on supported platforms and named pipes on Windows. If you want to
// configure transport options, use DialHTTP, DialWebsocket or DialIPC instead.
// domain sockets on supported platforms and named pipes on Windows.
//
// If you want to further configure the transport, use DialOptions instead of this
// function.
//
// For websocket connections, the origin is set to the local host name.
//
// The client reconnects automatically if the connection is lost.
// The client reconnects automatically when the connection is lost.
func Dial(rawurl string) (*Client, error) {
return DialContext(context.Background(), rawurl)
return DialOptions(context.Background(), rawurl)
}
// DialContext creates a new RPC client, just like Dial.
@@ -168,22 +171,46 @@ func Dial(rawurl string) (*Client, error) {
// The context is used to cancel or time out the initial connection establishment. It does
// not affect subsequent interactions with the client.
func DialContext(ctx context.Context, rawurl string) (*Client, error) {
return DialOptions(ctx, rawurl)
}
// DialOptions creates a new RPC client for the given URL. You can supply any of the
// pre-defined client options to configure the underlying transport.
//
// The context is used to cancel or time out the initial connection establishment. It does
// not affect subsequent interactions with the client.
//
// The client reconnects automatically when the connection is lost.
func DialOptions(ctx context.Context, rawurl string, options ...ClientOption) (*Client, error) {
u, err := url.Parse(rawurl)
if err != nil {
return nil, err
}
cfg := new(clientConfig)
for _, opt := range options {
opt.applyOption(cfg)
}
var reconnect reconnectFunc
switch u.Scheme {
case "http", "https":
return DialHTTP(rawurl)
reconnect = newClientTransportHTTP(rawurl, cfg)
case "ws", "wss":
return DialWebsocket(ctx, rawurl, "")
rc, err := newClientTransportWS(rawurl, cfg)
if err != nil {
return nil, err
}
reconnect = rc
case "stdio":
return DialStdIO(ctx)
reconnect = newClientTransportIO(os.Stdin, os.Stdout)
case "":
return DialIPC(ctx, rawurl)
reconnect = newClientTransportIPC(rawurl)
default:
return nil, fmt.Errorf("no known transport for URL scheme %q", u.Scheme)
}
return newClient(ctx, reconnect)
}
// ClientFromContext retrieves the client from the context, if any. This can be used to perform
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2022 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
package rpc
import (
"net/http"
"github.com/gorilla/websocket"
)
// ClientOption is a configuration option for the RPC client.
type ClientOption interface {
applyOption(*clientConfig)
}
type clientConfig struct {
httpClient *http.Client
httpHeaders http.Header
httpAuth HTTPAuth
wsDialer *websocket.Dialer
}
func (cfg *clientConfig) initHeaders() {
if cfg.httpHeaders == nil {
cfg.httpHeaders = make(http.Header)
}
}
func (cfg *clientConfig) setHeader(key, value string) {
cfg.initHeaders()
cfg.httpHeaders.Set(key, value)
}
type optionFunc func(*clientConfig)
func (fn optionFunc) applyOption(opt *clientConfig) {
fn(opt)
}
// WithWebsocketDialer configures the websocket.Dialer used by the RPC client.
func WithWebsocketDialer(dialer websocket.Dialer) ClientOption {
return optionFunc(func(cfg *clientConfig) {
cfg.wsDialer = &dialer
})
}
// WithHeader configures HTTP headers set by the RPC client. Headers set using this option
// will be used for both HTTP and WebSocket connections.
func WithHeader(key, value string) ClientOption {
return optionFunc(func(cfg *clientConfig) {
cfg.initHeaders()
cfg.httpHeaders.Set(key, value)
})
}
// WithHeaders configures HTTP headers set by the RPC client. Headers set using this
// option will be used for both HTTP and WebSocket connections.
func WithHeaders(headers http.Header) ClientOption {
return optionFunc(func(cfg *clientConfig) {
cfg.initHeaders()
for k, vs := range headers {
cfg.httpHeaders[k] = vs
}
})
}
// WithHTTPClient configures the http.Client used by the RPC client.
func WithHTTPClient(c *http.Client) ClientOption {
return optionFunc(func(cfg *clientConfig) {
cfg.httpClient = c
})
}
// WithHTTPAuth configures HTTP request authentication. The given provider will be called
// whenever a request is made. Note that only one authentication provider can be active at
// any time.
func WithHTTPAuth(a HTTPAuth) ClientOption {
if a == nil {
panic("nil auth")
}
return optionFunc(func(cfg *clientConfig) {
cfg.httpAuth = a
})
}
// A HTTPAuth function is called by the client whenever a HTTP request is sent.
// The function must be safe for concurrent use.
//
// Usually, HTTPAuth functions will call h.Set("authorization", "...") to add
// auth information to the request.
type HTTPAuth func(h http.Header) error
+25
View File
@@ -0,0 +1,25 @@
package rpc_test
import (
"context"
"net/http"
"time"
"github.com/ethereum/go-ethereum/rpc"
)
// This example configures a HTTP-based RPC client with two options - one setting the
// overall request timeout, the other adding a custom HTTP header to all requests.
func ExampleDialOptions() {
tokenHeader := rpc.WithHeader("x-token", "foo")
httpClient := rpc.WithHTTPClient(&http.Client{
Timeout: 10 * time.Second,
})
ctx := context.Background()
c, err := rpc.DialOptions(ctx, "http://rpc.example.com", httpClient, tokenHeader)
if err != nil {
panic(err)
}
c.Close()
}
+40 -16
View File
@@ -45,6 +45,7 @@ type httpConn struct {
closeCh chan interface{}
mu sync.Mutex // protects headers
headers http.Header
auth HTTPAuth
}
// httpConn implements ServerCodec, but it is treated specially by Client
@@ -117,8 +118,15 @@ var DefaultHTTPTimeouts = HTTPTimeouts{
IdleTimeout: 120 * time.Second,
}
// DialHTTP creates a new RPC client that connects to an RPC server over HTTP.
func DialHTTP(endpoint string) (*Client, error) {
return DialHTTPWithClient(endpoint, new(http.Client))
}
// DialHTTPWithClient creates a new RPC client that connects to an RPC server over HTTP
// using the provided HTTP Client.
//
// Deprecated: use DialOptions and the WithHTTPClient option.
func DialHTTPWithClient(endpoint string, client *http.Client) (*Client, error) {
// Sanity check URL so we don't end up with a client that will fail every request.
_, err := url.Parse(endpoint)
@@ -126,24 +134,35 @@ func DialHTTPWithClient(endpoint string, client *http.Client) (*Client, error) {
return nil, err
}
initctx := context.Background()
headers := make(http.Header, 2)
headers.Set("accept", contentType)
headers.Set("content-type", contentType)
return newClient(initctx, func(context.Context) (ServerCodec, error) {
hc := &httpConn{
client: client,
headers: headers,
url: endpoint,
closeCh: make(chan interface{}),
}
return hc, nil
})
var cfg clientConfig
fn := newClientTransportHTTP(endpoint, &cfg)
return newClient(context.Background(), fn)
}
// DialHTTP creates a new RPC client that connects to an RPC server over HTTP.
func DialHTTP(endpoint string) (*Client, error) {
return DialHTTPWithClient(endpoint, new(http.Client))
func newClientTransportHTTP(endpoint string, cfg *clientConfig) reconnectFunc {
headers := make(http.Header, 2+len(cfg.httpHeaders))
headers.Set("accept", contentType)
headers.Set("content-type", contentType)
for key, values := range cfg.httpHeaders {
headers[key] = values
}
client := cfg.httpClient
if client == nil {
client = new(http.Client)
}
hc := &httpConn{
client: client,
headers: headers,
url: endpoint,
auth: cfg.httpAuth,
closeCh: make(chan interface{}),
}
return func(ctx context.Context) (ServerCodec, error) {
return hc, nil
}
}
func (c *Client) sendHTTP(ctx context.Context, op *requestOp, msg interface{}) error {
@@ -195,6 +214,11 @@ func (hc *httpConn) doRequest(ctx context.Context, msg interface{}) (io.ReadClos
hc.mu.Lock()
req.Header = hc.headers.Clone()
hc.mu.Unlock()
if hc.auth != nil {
if err := hc.auth(req.Header); err != nil {
return nil, err
}
}
// do request
resp, err := hc.client.Do(req)
+6 -2
View File
@@ -46,11 +46,15 @@ func (s *Server) ServeListener(l net.Listener) error {
// The context is used for the initial connection establishment. It does not
// affect subsequent interactions with the client.
func DialIPC(ctx context.Context, endpoint string) (*Client, error) {
return newClient(ctx, func(ctx context.Context) (ServerCodec, error) {
return newClient(ctx, newClientTransportIPC(endpoint))
}
func newClientTransportIPC(endpoint string) reconnectFunc {
return func(ctx context.Context) (ServerCodec, error) {
conn, err := newIPCConnection(ctx, endpoint)
if err != nil {
return nil, err
}
return NewCodec(conn), err
})
}
}
+6 -2
View File
@@ -32,12 +32,16 @@ func DialStdIO(ctx context.Context) (*Client, error) {
// DialIO creates a client which uses the given IO channels
func DialIO(ctx context.Context, in io.Reader, out io.Writer) (*Client, error) {
return newClient(ctx, func(_ context.Context) (ServerCodec, error) {
return newClient(ctx, newClientTransportIO(in, out))
}
func newClientTransportIO(in io.Reader, out io.Writer) reconnectFunc {
return func(context.Context) (ServerCodec, error) {
return NewCodec(stdioConn{
in: in,
out: out,
}), nil
})
}
}
type stdioConn struct {
+59 -19
View File
@@ -181,24 +181,23 @@ func parseOriginURL(origin string) (string, string, string, error) {
return scheme, hostname, port, nil
}
// DialWebsocketWithDialer creates a new RPC client that communicates with a JSON-RPC server
// that is listening on the given endpoint using the provided dialer.
// DialWebsocketWithDialer creates a new RPC client using WebSocket.
//
// The context is used for the initial connection establishment. It does not
// affect subsequent interactions with the client.
//
// Deprecated: use DialOptions and the WithWebsocketDialer option.
func DialWebsocketWithDialer(ctx context.Context, endpoint, origin string, dialer websocket.Dialer) (*Client, error) {
endpoint, header, err := wsClientHeaders(endpoint, origin)
cfg := new(clientConfig)
cfg.wsDialer = &dialer
if origin != "" {
cfg.setHeader("origin", origin)
}
connect, err := newClientTransportWS(endpoint, cfg)
if err != nil {
return nil, err
}
return newClient(ctx, func(ctx context.Context) (ServerCodec, error) {
conn, resp, err := dialer.DialContext(ctx, endpoint, header)
if err != nil {
hErr := wsHandshakeError{err: err}
if resp != nil {
hErr.status = resp.Status
}
return nil, hErr
}
return newWebsocketCodec(conn, endpoint, header), nil
})
return newClient(ctx, connect)
}
// DialWebsocket creates a new RPC client that communicates with a JSON-RPC server
@@ -207,12 +206,53 @@ func DialWebsocketWithDialer(ctx context.Context, endpoint, origin string, diale
// The context is used for the initial connection establishment. It does not
// affect subsequent interactions with the client.
func DialWebsocket(ctx context.Context, endpoint, origin string) (*Client, error) {
dialer := websocket.Dialer{
ReadBufferSize: wsReadBuffer,
WriteBufferSize: wsWriteBuffer,
WriteBufferPool: wsBufferPool,
cfg := new(clientConfig)
if origin != "" {
cfg.setHeader("origin", origin)
}
return DialWebsocketWithDialer(ctx, endpoint, origin, dialer)
connect, err := newClientTransportWS(endpoint, cfg)
if err != nil {
return nil, err
}
return newClient(ctx, connect)
}
func newClientTransportWS(endpoint string, cfg *clientConfig) (reconnectFunc, error) {
dialer := cfg.wsDialer
if dialer == nil {
dialer = &websocket.Dialer{
ReadBufferSize: wsReadBuffer,
WriteBufferSize: wsWriteBuffer,
WriteBufferPool: wsBufferPool,
}
}
dialURL, header, err := wsClientHeaders(endpoint, "")
if err != nil {
return nil, err
}
for key, values := range cfg.httpHeaders {
header[key] = values
}
connect := func(ctx context.Context) (ServerCodec, error) {
header := header.Clone()
if cfg.httpAuth != nil {
if err := cfg.httpAuth(header); err != nil {
return nil, err
}
}
conn, resp, err := dialer.DialContext(ctx, dialURL, header)
if err != nil {
hErr := wsHandshakeError{err: err}
if resp != nil {
hErr.status = resp.Status
}
return nil, hErr
}
return newWebsocketCodec(conn, dialURL, header), nil
}
return connect, nil
}
func wsClientHeaders(endpoint, origin string) (string, http.Header, error) {