From ed9672620b73687b55b2cc5548b444fb171d0460 Mon Sep 17 00:00:00 2001 From: Igor Mandrigin Date: Tue, 10 Nov 2020 10:08:42 +0100 Subject: [PATCH] Granular rpc control (Allow list for RPC daemon) (#1341) --- cmd/rpcdaemon/README.md | 27 ++ cmd/rpcdaemon/cli/config.go | 47 ++- cmd/rpcdaemon/cli/rpc_allow_list.go | 43 +++ console/bridge.go | 487 ---------------------------- console/bridge_test.go | 49 --- console/console.go | 487 ---------------------------- console/console_test.go | 346 -------------------- console/testdata/exec.js | 1 - console/testdata/preload.js | 1 - node/api.go | 4 +- node/node.go | 11 +- node/node_test.go | 1 - node/rpcstack.go | 6 +- node/rpcstack_test.go | 51 ++- rpc/allow_list.go | 35 ++ rpc/allow_list_test.go | 23 ++ rpc/client.go | 9 +- rpc/handler.go | 15 +- rpc/server.go | 16 +- 19 files changed, 248 insertions(+), 1411 deletions(-) create mode 100644 cmd/rpcdaemon/cli/rpc_allow_list.go delete mode 100644 console/bridge.go delete mode 100644 console/bridge_test.go delete mode 100644 console/console.go delete mode 100644 console/console_test.go delete mode 100644 console/testdata/exec.js delete mode 100644 console/testdata/preload.js create mode 100644 rpc/allow_list.go create mode 100644 rpc/allow_list_test.go diff --git a/cmd/rpcdaemon/README.md b/cmd/rpcdaemon/README.md index 603a57150..1c5dba79d 100644 --- a/cmd/rpcdaemon/README.md +++ b/cmd/rpcdaemon/README.md @@ -305,6 +305,33 @@ WARN [11-05|09:03:47.911] Served conn=127.0.0. WARN [11-05|09:03:47.911] Served conn=127.0.0.1:59754 method=eth_newPendingTransactionFilter reqid=6 t="9.053µs" err="the method eth_newPendingTransactionFilter does not exist/is not available" ``` +## Allowing only specific methods (Allowlist) + +In some cases you might want to only allow certain methods in the namespaces +and hide others. That is possible with `rpc.accessList` flag. + +1. Create a file, say, `rules.json` + +2. Add the following content + +```json +{ + "allow": [ + "net_version", + "web3_eth_getBlockByHash" + ] +} +``` + +3. Provide this file to the rpcdaemon using `--rpc.accessList` flag + +``` +> rpcdaemon --private.api.addr=localhost:9090 --http.api=eth,debug,net,web3 --rpc.accessList=rules.json +``` + +Now only these two methods are available. + + ## For Developers ### Code generation diff --git a/cmd/rpcdaemon/cli/config.go b/cmd/rpcdaemon/cli/config.go index 1ad135374..73cd1e99c 100644 --- a/cmd/rpcdaemon/cli/config.go +++ b/cmd/rpcdaemon/cli/config.go @@ -18,22 +18,23 @@ import ( ) type Flags struct { - PrivateApiAddr string - Chaindata string - SnapshotDir string - SnapshotMode string - HttpListenAddress string - TLSCertfile string - TLSCACert string - TLSKeyFile string - HttpPort int - HttpCORSDomain []string - HttpVirtualHost []string - API []string - Gascap uint64 - MaxTraces uint64 - TraceType string - WebsocketEnabled bool + PrivateApiAddr string + Chaindata string + SnapshotDir string + SnapshotMode string + HttpListenAddress string + TLSCertfile string + TLSCACert string + TLSKeyFile string + HttpPort int + HttpCORSDomain []string + HttpVirtualHost []string + API []string + Gascap uint64 + MaxTraces uint64 + TraceType string + WebsocketEnabled bool + RpcAllowListFilePath string } var rootCmd = &cobra.Command{ @@ -76,6 +77,11 @@ func RootCommand() (*cobra.Command, *Flags) { rootCmd.PersistentFlags().Uint64Var(&cfg.MaxTraces, "trace.maxtraces", 200, "Sets a limit on traces that can be returned in trace_filter") rootCmd.PersistentFlags().StringVar(&cfg.TraceType, "trace.type", "parity", "Specify the type of tracing [geth|parity*] (experimental)") rootCmd.PersistentFlags().BoolVar(&cfg.WebsocketEnabled, "ws", false, "Enable Websockets") + rootCmd.PersistentFlags().StringVar(&cfg.RpcAllowListFilePath, "rpc.accessList", "", "Specify granular (method-by-method) API allowlist") + + if err := rootCmd.MarkPersistentFlagFilename("rpc.accessList", "json"); err != nil { + panic(err) + } return rootCmd, cfg } @@ -124,12 +130,17 @@ func StartRpcServer(ctx context.Context, cfg Flags, rpcAPI []rpc.API) error { httpEndpoint := fmt.Sprintf("%s:%d", cfg.HttpListenAddress, cfg.HttpPort) srv := rpc.NewServer() + + allowListForRPC, err := parseAllowListForRPC(cfg.RpcAllowListFilePath) + if err != nil { + return err + } + srv.SetAllowList(allowListForRPC) + if err := node.RegisterApisFromWhitelist(rpcAPI, cfg.API, srv, false); err != nil { return fmt.Errorf("could not start register RPC apis: %w", err) } - var err error - httpHandler := node.NewHTTPHandlerStack(srv, cfg.HttpCORSDomain, cfg.HttpVirtualHost) var wsHandler http.Handler if cfg.WebsocketEnabled { diff --git a/cmd/rpcdaemon/cli/rpc_allow_list.go b/cmd/rpcdaemon/cli/rpc_allow_list.go new file mode 100644 index 000000000..9574d27b6 --- /dev/null +++ b/cmd/rpcdaemon/cli/rpc_allow_list.go @@ -0,0 +1,43 @@ +package cli + +import ( + "encoding/json" + "io/ioutil" + "os" + "strings" + + "github.com/ledgerwatch/turbo-geth/rpc" +) + +type allowListFile struct { + Allow rpc.AllowList `json:"allow"` +} + +func parseAllowListForRPC(path string) (rpc.AllowList, error) { + path = strings.TrimSpace(path) + if path == "" { // no file is provided + return nil, nil + } + + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { + file.Close() //nolint: errcheck + }() + + fileContents, err := ioutil.ReadAll(file) + if err != nil { + return nil, err + } + + var allowListFileObj allowListFile + + err = json.Unmarshal(fileContents, &allowListFileObj) + if err != nil { + return nil, err + } + + return allowListFileObj.Allow, nil +} diff --git a/console/bridge.go b/console/bridge.go deleted file mode 100644 index cdc7a445e..000000000 --- a/console/bridge.go +++ /dev/null @@ -1,487 +0,0 @@ -// Copyright 2016 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 . - -package console - -import ( - "encoding/json" - "fmt" - "io" - "reflect" - "strings" - "time" - - "github.com/dop251/goja" - "github.com/ledgerwatch/turbo-geth/accounts/scwallet" - "github.com/ledgerwatch/turbo-geth/accounts/usbwallet" - "github.com/ledgerwatch/turbo-geth/common/hexutil" - "github.com/ledgerwatch/turbo-geth/console/prompt" - "github.com/ledgerwatch/turbo-geth/internal/jsre" - "github.com/ledgerwatch/turbo-geth/rpc" -) - -// bridge is a collection of JavaScript utility methods to bride the .js runtime -// environment and the Go RPC connection backing the remote method calls. -type bridge struct { - client *rpc.Client // RPC client to execute Ethereum requests through - prompter prompt.UserPrompter // Input prompter to allow interactive user feedback - printer io.Writer // Output writer to serialize any display strings to -} - -// newBridge creates a new JavaScript wrapper around an RPC client. -func newBridge(client *rpc.Client, prompter prompt.UserPrompter, printer io.Writer) *bridge { - return &bridge{ - client: client, - prompter: prompter, - printer: printer, - } -} - -func getJeth(vm *goja.Runtime) *goja.Object { - jeth := vm.Get("jeth") - if jeth == nil { - panic(vm.ToValue("jeth object does not exist")) - } - return jeth.ToObject(vm) -} - -// NewAccount is a wrapper around the personal.newAccount RPC method that uses a -// non-echoing password prompt to acquire the passphrase and executes the original -// RPC method (saved in jeth.newAccount) with it to actually execute the RPC call. -func (b *bridge) NewAccount(call jsre.Call) (goja.Value, error) { - var ( - password string - confirm string - err error - ) - switch { - // No password was specified, prompt the user for it - case len(call.Arguments) == 0: - if password, err = b.prompter.PromptPassword("Passphrase: "); err != nil { - return nil, err - } - if confirm, err = b.prompter.PromptPassword("Repeat passphrase: "); err != nil { - return nil, err - } - if password != confirm { - return nil, fmt.Errorf("passwords don't match") - } - // A single string password was specified, use that - case len(call.Arguments) == 1 && call.Argument(0).ToString() != nil: - password = call.Argument(0).ToString().String() - default: - return nil, fmt.Errorf("expected 0 or 1 string argument") - } - // Password acquired, execute the call and return - newAccount, callable := goja.AssertFunction(getJeth(call.VM).Get("newAccount")) - if !callable { - return nil, fmt.Errorf("jeth.newAccount is not callable") - } - ret, err := newAccount(goja.Null(), call.VM.ToValue(password)) - if err != nil { - return nil, err - } - return ret, nil -} - -// OpenWallet is a wrapper around personal.openWallet which can interpret and -// react to certain error messages, such as the Trezor PIN matrix request. -func (b *bridge) OpenWallet(call jsre.Call) (goja.Value, error) { - // Make sure we have a wallet specified to open - if call.Argument(0).ToObject(call.VM).ClassName() != "String" { - return nil, fmt.Errorf("first argument must be the wallet URL to open") - } - wallet := call.Argument(0) - - var passwd goja.Value - if goja.IsUndefined(call.Argument(1)) || goja.IsNull(call.Argument(1)) { - passwd = call.VM.ToValue("") - } else { - passwd = call.Argument(1) - } - // Open the wallet and return if successful in itself - openWallet, callable := goja.AssertFunction(getJeth(call.VM).Get("openWallet")) - if !callable { - return nil, fmt.Errorf("jeth.openWallet is not callable") - } - val, err := openWallet(goja.Null(), wallet, passwd) - if err == nil { - return val, nil - } - - // Wallet open failed, report error unless it's a PIN or PUK entry - switch { - case strings.HasSuffix(err.Error(), usbwallet.ErrTrezorPINNeeded.Error()): - val, err = b.readPinAndReopenWallet(call) - if err == nil { - return val, nil - } - val, err = b.readPassphraseAndReopenWallet(call) - if err != nil { - return nil, err - } - - case strings.HasSuffix(err.Error(), scwallet.ErrPairingPasswordNeeded.Error()): - // PUK input requested, fetch from the user and call open again - var input string - input, err = b.prompter.PromptPassword("Please enter the pairing password: ") - if err != nil { - return nil, err - } - passwd = call.VM.ToValue(input) - if val, err = openWallet(goja.Null(), wallet, passwd); err != nil { - if !strings.HasSuffix(err.Error(), scwallet.ErrPINNeeded.Error()) { - return nil, err - } else { - // PIN input requested, fetch from the user and call open again - input, err = b.prompter.PromptPassword("Please enter current PIN: ") - if err != nil { - return nil, err - } - if val, err = openWallet(goja.Null(), wallet, call.VM.ToValue(input)); err != nil { - return nil, err - } - } - } - - case strings.HasSuffix(err.Error(), scwallet.ErrPINUnblockNeeded.Error()): - // PIN unblock requested, fetch PUK and new PIN from the user - var pukpin string - var input string - input, err = b.prompter.PromptPassword("Please enter current PUK: ") - if err != nil { - return nil, err - } - pukpin = input - input, err = b.prompter.PromptPassword("Please enter new PIN: ") - if err != nil { - return nil, err - } - pukpin += input - - if val, err = openWallet(goja.Null(), wallet, call.VM.ToValue(pukpin)); err != nil { - return nil, err - } - - case strings.HasSuffix(err.Error(), scwallet.ErrPINNeeded.Error()): - // PIN input requested, fetch from the user and call open again - var input string - input, err = b.prompter.PromptPassword("Please enter current PIN: ") - if err != nil { - return nil, err - } - if val, err = openWallet(goja.Null(), wallet, call.VM.ToValue(input)); err != nil { - return nil, err - } - - default: - // Unknown error occurred, drop to the user - return nil, err - } - return val, nil -} - -func (b *bridge) readPassphraseAndReopenWallet(call jsre.Call) (goja.Value, error) { - wallet := call.Argument(0) - input, err := b.prompter.PromptPassword("Please enter your passphrase: ") - if err != nil { - return nil, err - } - openWallet, callable := goja.AssertFunction(getJeth(call.VM).Get("openWallet")) - if !callable { - return nil, fmt.Errorf("jeth.openWallet is not callable") - } - return openWallet(goja.Null(), wallet, call.VM.ToValue(input)) -} - -func (b *bridge) readPinAndReopenWallet(call jsre.Call) (goja.Value, error) { - wallet := call.Argument(0) - // Trezor PIN matrix input requested, display the matrix to the user and fetch the data - fmt.Fprintf(b.printer, "Look at the device for number positions\n\n") - fmt.Fprintf(b.printer, "7 | 8 | 9\n") - fmt.Fprintf(b.printer, "--+---+--\n") - fmt.Fprintf(b.printer, "4 | 5 | 6\n") - fmt.Fprintf(b.printer, "--+---+--\n") - fmt.Fprintf(b.printer, "1 | 2 | 3\n\n") - - input, err := b.prompter.PromptPassword("Please enter current PIN: ") - if err != nil { - return nil, err - } - openWallet, callable := goja.AssertFunction(getJeth(call.VM).Get("openWallet")) - if !callable { - return nil, fmt.Errorf("jeth.openWallet is not callable") - } - return openWallet(goja.Null(), wallet, call.VM.ToValue(input)) -} - -// UnlockAccount is a wrapper around the personal.unlockAccount RPC method that -// uses a non-echoing password prompt to acquire the passphrase and executes the -// original RPC method (saved in jeth.unlockAccount) with it to actually execute -// the RPC call. -func (b *bridge) UnlockAccount(call jsre.Call) (goja.Value, error) { - if len(call.Arguments) < 1 { - return nil, fmt.Errorf("usage: unlockAccount(account, [ password, duration ])") - } - - account := call.Argument(0) - // Make sure we have an account specified to unlock. - if goja.IsUndefined(account) || goja.IsNull(account) || account.ExportType().Kind() != reflect.String { - return nil, fmt.Errorf("first argument must be the account to unlock") - } - - // If password is not given or is the null value, prompt the user for it. - var passwd goja.Value - if goja.IsUndefined(call.Argument(1)) || goja.IsNull(call.Argument(1)) { - fmt.Fprintf(b.printer, "Unlock account %s\n", account) - input, err := b.prompter.PromptPassword("Passphrase: ") - if err != nil { - return nil, err - } - passwd = call.VM.ToValue(input) - } else { - if call.Argument(1).ExportType().Kind() != reflect.String { - return nil, fmt.Errorf("password must be a string") - } - passwd = call.Argument(1) - } - - // Third argument is the duration how long the account should be unlocked. - duration := goja.Null() - if !goja.IsUndefined(call.Argument(2)) && !goja.IsNull(call.Argument(2)) { - if !isNumber(call.Argument(2)) { - return nil, fmt.Errorf("unlock duration must be a number") - } - duration = call.Argument(2) - } - - // Send the request to the backend and return. - unlockAccount, callable := goja.AssertFunction(getJeth(call.VM).Get("unlockAccount")) - if !callable { - return nil, fmt.Errorf("jeth.unlockAccount is not callable") - } - return unlockAccount(goja.Null(), account, passwd, duration) -} - -// Sign is a wrapper around the personal.sign RPC method that uses a non-echoing password -// prompt to acquire the passphrase and executes the original RPC method (saved in -// jeth.sign) with it to actually execute the RPC call. -func (b *bridge) Sign(call jsre.Call) (goja.Value, error) { - if nArgs := len(call.Arguments); nArgs < 2 { - return nil, fmt.Errorf("usage: sign(message, account, [ password ])") - } - var ( - message = call.Argument(0) - account = call.Argument(1) - passwd = call.Argument(2) - ) - - if goja.IsUndefined(message) || message.ExportType().Kind() != reflect.String { - return nil, fmt.Errorf("first argument must be the message to sign") - } - if goja.IsUndefined(account) || account.ExportType().Kind() != reflect.String { - return nil, fmt.Errorf("second argument must be the account to sign with") - } - - // if the password is not given or null ask the user and ensure password is a string - if goja.IsUndefined(passwd) || goja.IsNull(passwd) { - fmt.Fprintf(b.printer, "Give password for account %s\n", account) - input, err := b.prompter.PromptPassword("Password: ") - if err != nil { - return nil, err - } - passwd = call.VM.ToValue(input) - } else if passwd.ExportType().Kind() != reflect.String { - return nil, fmt.Errorf("third argument must be the password to unlock the account") - } - - // Send the request to the backend and return - sign, callable := goja.AssertFunction(getJeth(call.VM).Get("sign")) - if !callable { - return nil, fmt.Errorf("jeth.sign is not callable") - } - return sign(goja.Null(), message, account, passwd) -} - -// Sleep will block the console for the specified number of seconds. -func (b *bridge) Sleep(call jsre.Call) (goja.Value, error) { - if nArgs := len(call.Arguments); nArgs < 1 { - return nil, fmt.Errorf("usage: sleep()") - } - sleepObj := call.Argument(0) - if goja.IsUndefined(sleepObj) || goja.IsNull(sleepObj) || !isNumber(sleepObj) { - return nil, fmt.Errorf("usage: sleep()") - } - sleep := sleepObj.ToFloat() - time.Sleep(time.Duration(sleep * float64(time.Second))) - return call.VM.ToValue(true), nil -} - -// SleepBlocks will block the console for a specified number of new blocks optionally -// until the given timeout is reached. -func (b *bridge) SleepBlocks(call jsre.Call) (goja.Value, error) { - // Parse the input parameters for the sleep. - var ( - blocks = int64(0) - sleep = int64(9999999999999999) // indefinitely - ) - nArgs := len(call.Arguments) - if nArgs == 0 { - return nil, fmt.Errorf("usage: sleepBlocks([, max sleep in seconds])") - } - if nArgs >= 1 { - if goja.IsNull(call.Argument(0)) || goja.IsUndefined(call.Argument(0)) || !isNumber(call.Argument(0)) { - return nil, fmt.Errorf("expected number as first argument") - } - blocks = call.Argument(0).ToInteger() - } - if nArgs >= 2 { - if goja.IsNull(call.Argument(1)) || goja.IsUndefined(call.Argument(1)) || !isNumber(call.Argument(1)) { - return nil, fmt.Errorf("expected number as second argument") - } - sleep = call.Argument(1).ToInteger() - } - - // Poll the current block number until either it or a timeout is reached. - deadline := time.Now().Add(time.Duration(sleep) * time.Second) - var lastNumber hexutil.Uint64 - if err := b.client.Call(&lastNumber, "eth_blockNumber"); err != nil { - return nil, err - } - for time.Now().Before(deadline) { - var number hexutil.Uint64 - if err := b.client.Call(&number, "eth_blockNumber"); err != nil { - return nil, err - } - if number != lastNumber { - lastNumber = number - blocks-- - } - if blocks <= 0 { - break - } - time.Sleep(time.Second) - } - return call.VM.ToValue(true), nil -} - -type jsonrpcCall struct { - ID int64 - Method string - Params []interface{} -} - -// Send implements the web3 provider "send" method. -func (b *bridge) Send(call jsre.Call) (goja.Value, error) { - // Remarshal the request into a Go value. - reqVal, err := call.Argument(0).ToObject(call.VM).MarshalJSON() - if err != nil { - return nil, err - } - - var ( - rawReq = string(reqVal) - dec = json.NewDecoder(strings.NewReader(rawReq)) - reqs []jsonrpcCall - batch bool - ) - dec.UseNumber() // avoid float64s - if rawReq[0] == '[' { - batch = true - dec.Decode(&reqs) - } else { - batch = false - reqs = make([]jsonrpcCall, 1) - dec.Decode(&reqs[0]) - } - - // Execute the requests. - resps := make([]*goja.Object, 0, len(reqs)) - for _, req := range reqs { - resp := call.VM.NewObject() - resp.Set("jsonrpc", "2.0") //nolint:errcheck - resp.Set("id", req.ID) //nolint:errcheck - - var result json.RawMessage - if err = b.client.Call(&result, req.Method, req.Params...); err == nil { - if result == nil { - // Special case null because it is decoded as an empty - // raw message for some reason. - resp.Set("result", goja.Null()) // nolint:errcheck - } else { - JSON := call.VM.Get("JSON").ToObject(call.VM) - parse, callable := goja.AssertFunction(JSON.Get("parse")) - if !callable { - return nil, fmt.Errorf("JSON.parse is not a function") - } - resultVal, err := parse(goja.Null(), call.VM.ToValue(string(result))) - if err != nil { - setError(resp, -32603, err.Error(), nil) - } else { - resp.Set("result", resultVal) - } - } - } else { - code := -32603 - var data interface{} - if err, ok := err.(rpc.Error); ok { - code = err.ErrorCode() - } - if err, ok := err.(rpc.DataError); ok { - data = err.ErrorData() - } - setError(resp, code, err.Error(), data) - } - resps = append(resps, resp) - } - // Return the responses either to the callback (if supplied) - // or directly as the return value. - var result goja.Value - if batch { - result = call.VM.ToValue(resps) - } else { - result = resps[0] - } - if fn, isFunc := goja.AssertFunction(call.Argument(1)); isFunc { - _, err := fn(goja.Null(), goja.Null(), result) - return goja.Undefined(), err - } - return result, nil -} - -func setError(resp *goja.Object, code int, msg string, data interface{}) { - err := make(map[string]interface{}) - err["code"] = code - err["message"] = msg - if data != nil { - err["data"] = data - } - resp.Set("error", err) //nolint:errcheck -} - -// isNumber returns true if input value is a JS number. -func isNumber(v goja.Value) bool { - k := v.ExportType().Kind() - return k >= reflect.Int && k <= reflect.Float64 -} - -func getObject(vm *goja.Runtime, name string) *goja.Object { - v := vm.Get(name) - if v == nil { - return nil - } - return v.ToObject(vm) -} diff --git a/console/bridge_test.go b/console/bridge_test.go deleted file mode 100644 index 0d1e566ec..000000000 --- a/console/bridge_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2020 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 . - -//nolint:errcheck -package console - -import ( - "testing" - - "github.com/dop251/goja" - "github.com/ledgerwatch/turbo-geth/internal/jsre" -) - -// TestUndefinedAsParam ensures that personal functions can receive -// `undefined` as a parameter. -func TestUndefinedAsParam(t *testing.T) { - b := bridge{} - call := jsre.Call{} - call.Arguments = []goja.Value{goja.Undefined()} - - b.UnlockAccount(call) - b.Sign(call) - b.Sleep(call) -} - -// TestNullAsParam ensures that personal functions can receive -// `null` as a parameter. -func TestNullAsParam(t *testing.T) { - b := bridge{} - call := jsre.Call{} - call.Arguments = []goja.Value{goja.Null()} - - b.UnlockAccount(call) - b.Sign(call) - b.Sleep(call) -} diff --git a/console/console.go b/console/console.go deleted file mode 100644 index 51f0fe196..000000000 --- a/console/console.go +++ /dev/null @@ -1,487 +0,0 @@ -// Copyright 2016 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 . - -// nolint:errcheck -package console - -import ( - "fmt" - "io" - "io/ioutil" - "os" - "os/signal" - "path/filepath" - "regexp" - "sort" - "strings" - "syscall" - - "github.com/dop251/goja" - "github.com/ledgerwatch/turbo-geth/console/prompt" - "github.com/ledgerwatch/turbo-geth/internal/jsre" - "github.com/ledgerwatch/turbo-geth/internal/jsre/deps" - "github.com/ledgerwatch/turbo-geth/internal/web3ext" - "github.com/ledgerwatch/turbo-geth/rpc" - "github.com/mattn/go-colorable" - "github.com/peterh/liner" -) - -var ( - // u: unlock, s: signXX, sendXX, n: newAccount, i: importXX - passwordRegexp = regexp.MustCompile(`personal.[nusi]`) - onlyWhitespace = regexp.MustCompile(`^\s*$`) - exit = regexp.MustCompile(`^\s*exit\s*;*\s*$`) -) - -// HistoryFile is the file within the data directory to store input scrollback. -const HistoryFile = "history" - -// DefaultPrompt is the default prompt line prefix to use for user input querying. -const DefaultPrompt = "> " - -// Config is the collection of configurations to fine tune the behavior of the -// JavaScript console. -type Config struct { - DataDir string // Data directory to store the console history at - DocRoot string // Filesystem path from where to load JavaScript files from - Client *rpc.Client // RPC client to execute Ethereum requests through - Prompt string // Input prompt prefix string (defaults to DefaultPrompt) - Prompter prompt.UserPrompter // Input prompter to allow interactive user feedback (defaults to TerminalPrompter) - Printer io.Writer // Output writer to serialize any display strings to (defaults to os.Stdout) - Preload []string // Absolute paths to JavaScript files to preload -} - -// Console is a JavaScript interpreted runtime environment. It is a fully fledged -// JavaScript console attached to a running node via an external or in-process RPC -// client. -type Console struct { - client *rpc.Client // RPC client to execute Ethereum requests through - jsre *jsre.JSRE // JavaScript runtime environment running the interpreter - prompt string // Input prompt prefix string - prompter prompt.UserPrompter // Input prompter to allow interactive user feedback - histPath string // Absolute path to the console scrollback history - history []string // Scroll history maintained by the console - printer io.Writer // Output writer to serialize any display strings to -} - -// New initializes a JavaScript interpreted runtime environment and sets defaults -// with the config struct. -func New(config Config) (*Console, error) { - // Handle unset config values gracefully - if config.Prompter == nil { - config.Prompter = prompt.Stdin - } - if config.Prompt == "" { - config.Prompt = DefaultPrompt - } - if config.Printer == nil { - config.Printer = colorable.NewColorableStdout() - } - - // Initialize the console and return - console := &Console{ - client: config.Client, - jsre: jsre.New(config.DocRoot, config.Printer), - prompt: config.Prompt, - prompter: config.Prompter, - printer: config.Printer, - histPath: filepath.Join(config.DataDir, HistoryFile), - } - if err := os.MkdirAll(config.DataDir, 0700); err != nil { - return nil, err - } - if err := console.init(config.Preload); err != nil { - return nil, err - } - return console, nil -} - -// init retrieves the available APIs from the remote RPC provider and initializes -// the console's JavaScript namespaces based on the exposed modules. -func (c *Console) init(preload []string) error { - c.initConsoleObject() - - // Initialize the JavaScript <-> Go RPC bridge. - bridge := newBridge(c.client, c.prompter, c.printer) - if err := c.initWeb3(bridge); err != nil { - return err - } - if err := c.initExtensions(); err != nil { - return err - } - - // Add bridge overrides for web3.js functionality. - c.jsre.Do(func(vm *goja.Runtime) { - c.initAdmin(vm, bridge) - c.initPersonal(vm, bridge) - }) - - // Preload JavaScript files. - for _, path := range preload { - if err := c.jsre.Exec(path); err != nil { - failure := err.Error() - if gojaErr, ok := err.(*goja.Exception); ok { - failure = gojaErr.String() - } - return fmt.Errorf("%s: %v", path, failure) - } - } - - // Configure the input prompter for history and tab completion. - if c.prompter != nil { - if content, err := ioutil.ReadFile(c.histPath); err != nil { - c.prompter.SetHistory(nil) - } else { - c.history = strings.Split(string(content), "\n") - c.prompter.SetHistory(c.history) - } - c.prompter.SetWordCompleter(c.AutoCompleteInput) - } - return nil -} - -func (c *Console) initConsoleObject() { - c.jsre.Do(func(vm *goja.Runtime) { - console := vm.NewObject() - console.Set("log", c.consoleOutput) - console.Set("error", c.consoleOutput) - vm.Set("console", console) - }) -} - -func (c *Console) initWeb3(bridge *bridge) error { - bnJS := string(deps.MustAsset("bignumber.js")) - web3JS := string(deps.MustAsset("web3.js")) - if err := c.jsre.Compile("bignumber.js", bnJS); err != nil { - return fmt.Errorf("bignumber.js: %v", err) - } - if err := c.jsre.Compile("web3.js", web3JS); err != nil { - return fmt.Errorf("web3.js: %v", err) - } - if _, err := c.jsre.Run("var Web3 = require('web3');"); err != nil { - return fmt.Errorf("web3 require: %v", err) - } - var err error - c.jsre.Do(func(vm *goja.Runtime) { - transport := vm.NewObject() - transport.Set("send", jsre.MakeCallback(vm, bridge.Send)) - transport.Set("sendAsync", jsre.MakeCallback(vm, bridge.Send)) - vm.Set("_consoleWeb3Transport", transport) - _, err = vm.RunString("var web3 = new Web3(_consoleWeb3Transport)") - }) - return err -} - -// initExtensions loads and registers web3.js extensions. -func (c *Console) initExtensions() error { - // Compute aliases from server-provided modules. - apis, err := c.client.SupportedModules() - if err != nil { - return fmt.Errorf("api modules: %v", err) - } - aliases := map[string]struct{}{"eth": {}, "personal": {}} - for api := range apis { - if api == "web3" { - continue - } - aliases[api] = struct{}{} - if file, ok := web3ext.Modules[api]; ok { - if err = c.jsre.Compile(api+".js", file); err != nil { - return fmt.Errorf("%s.js: %v", api, err) - } - } - } - - // Apply aliases. - c.jsre.Do(func(vm *goja.Runtime) { - web3 := getObject(vm, "web3") - for name := range aliases { - if v := web3.Get(name); v != nil { - vm.Set(name, v) - } - } - }) - return nil -} - -// initAdmin creates additional admin APIs implemented by the bridge. -func (c *Console) initAdmin(vm *goja.Runtime, bridge *bridge) { - if admin := getObject(vm, "admin"); admin != nil { - admin.Set("sleepBlocks", jsre.MakeCallback(vm, bridge.SleepBlocks)) - admin.Set("sleep", jsre.MakeCallback(vm, bridge.Sleep)) - admin.Set("clearHistory", c.clearHistory) - } -} - -// initPersonal redirects account-related API methods through the bridge. -// -// If the console is in interactive mode and the 'personal' API is available, override -// the openWallet, unlockAccount, newAccount and sign methods since these require user -// interaction. The original web3 callbacks are stored in 'jeth'. These will be called -// by the bridge after the prompt and send the original web3 request to the backend. -func (c *Console) initPersonal(vm *goja.Runtime, bridge *bridge) { - personal := getObject(vm, "personal") - if personal == nil || c.prompter == nil { - return - } - jeth := vm.NewObject() - vm.Set("jeth", jeth) - jeth.Set("openWallet", personal.Get("openWallet")) - jeth.Set("unlockAccount", personal.Get("unlockAccount")) - jeth.Set("newAccount", personal.Get("newAccount")) - jeth.Set("sign", personal.Get("sign")) - personal.Set("openWallet", jsre.MakeCallback(vm, bridge.OpenWallet)) - personal.Set("unlockAccount", jsre.MakeCallback(vm, bridge.UnlockAccount)) - personal.Set("newAccount", jsre.MakeCallback(vm, bridge.NewAccount)) - personal.Set("sign", jsre.MakeCallback(vm, bridge.Sign)) -} - -func (c *Console) clearHistory() { - c.history = nil - c.prompter.ClearHistory() - if err := os.Remove(c.histPath); err != nil { - fmt.Fprintln(c.printer, "can't delete history file:", err) - } else { - fmt.Fprintln(c.printer, "history file deleted") - } -} - -// consoleOutput is an override for the console.log and console.error methods to -// stream the output into the configured output stream instead of stdout. -func (c *Console) consoleOutput(call goja.FunctionCall) goja.Value { - var output []string - for _, argument := range call.Arguments { - output = append(output, fmt.Sprintf("%v", argument)) - } - fmt.Fprintln(c.printer, strings.Join(output, " ")) - return goja.Null() -} - -// AutoCompleteInput is a pre-assembled word completer to be used by the user -// input prompter to provide hints to the user about the methods available. -func (c *Console) AutoCompleteInput(line string, pos int) (string, []string, string) { - // No completions can be provided for empty inputs - if len(line) == 0 || pos == 0 { - return "", nil, "" - } - // Chunck data to relevant part for autocompletion - // E.g. in case of nested lines eth.getBalance(eth.coinb - start := pos - 1 - for ; start > 0; start-- { - // Skip all methods and namespaces (i.e. including the dot) - if line[start] == '.' || (line[start] >= 'a' && line[start] <= 'z') || (line[start] >= 'A' && line[start] <= 'Z') { - continue - } - // Handle web3 in a special way (i.e. other numbers aren't auto completed) - if start >= 3 && line[start-3:start] == "web3" { - start -= 3 - continue - } - // We've hit an unexpected character, autocomplete form here - start++ - break - } - return line[:start], c.jsre.CompleteKeywords(line[start:pos]), line[pos:] -} - -// Welcome show summary of current Geth instance and some metadata about the -// console's available modules. -func (c *Console) Welcome() { - message := "Welcome to the Geth JavaScript console!\n\n" - - // Print some generic Geth metadata - if res, err := c.jsre.Run(` - var message = "instance: " + web3.version.node + "\n"; - try { - message += "coinbase: " + eth.coinbase + "\n"; - } catch (err) {} - message += "at block: " + eth.blockNumber + " (" + new Date(1000 * eth.getBlock(eth.blockNumber).timestamp) + ")\n"; - try { - message += " datadir: " + admin.datadir + "\n"; - } catch (err) {} - message - `); err == nil { - message += res.String() - } - // List all the supported modules for the user to call - if apis, err := c.client.SupportedModules(); err == nil { - modules := make([]string, 0, len(apis)) - for api, version := range apis { - modules = append(modules, fmt.Sprintf("%s:%s", api, version)) - } - sort.Strings(modules) - message += " modules: " + strings.Join(modules, " ") + "\n" - } - fmt.Fprintln(c.printer, message) -} - -// Evaluate executes code and pretty prints the result to the specified output -// stream. -func (c *Console) Evaluate(statement string) { - defer func() { - if r := recover(); r != nil { - fmt.Fprintf(c.printer, "[native] error: %v\n", r) - } - }() - c.jsre.Evaluate(statement, c.printer) -} - -// Interactive starts an interactive user session, where input is propted from -// the configured user prompter. -func (c *Console) Interactive() { - var ( - prompt = c.prompt // the current prompt line (used for multi-line inputs) - indents = 0 // the current number of input indents (used for multi-line inputs) - input = "" // the current user input - inputLine = make(chan string, 1) // receives user input - inputErr = make(chan error, 1) // receives liner errors - requestLine = make(chan string) // requests a line of input - interrupt = make(chan os.Signal, 1) - ) - - // Monitor Ctrl-C. While liner does turn on the relevant terminal mode bits to avoid - // the signal, a signal can still be received for unsupported terminals. Unfortunately - // there is no way to cancel the line reader when this happens. The readLines - // goroutine will be leaked in this case. - signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) - defer signal.Stop(interrupt) - - // The line reader runs in a separate goroutine. - go c.readLines(inputLine, inputErr, requestLine) - defer close(requestLine) - - for { - // Send the next prompt, triggering an input read. - requestLine <- prompt - - select { - case <-interrupt: - fmt.Fprintln(c.printer, "caught interrupt, exiting") - return - - case err := <-inputErr: - if err == liner.ErrPromptAborted && indents > 0 { - // When prompting for multi-line input, the first Ctrl-C resets - // the multi-line state. - prompt, indents, input = c.prompt, 0, "" - continue - } - return - - case line := <-inputLine: - // User input was returned by the prompter, handle special cases. - if indents <= 0 && exit.MatchString(line) { - return - } - if onlyWhitespace.MatchString(line) { - continue - } - // Append the line to the input and check for multi-line interpretation. - input += line + "\n" - indents = countIndents(input) - if indents <= 0 { - prompt = c.prompt - } else { - prompt = strings.Repeat(".", indents*3) + " " - } - // If all the needed lines are present, save the command and run it. - if indents <= 0 { - if len(input) > 0 && input[0] != ' ' && !passwordRegexp.MatchString(input) { - if command := strings.TrimSpace(input); len(c.history) == 0 || command != c.history[len(c.history)-1] { - c.history = append(c.history, command) - if c.prompter != nil { - c.prompter.AppendHistory(command) - } - } - } - c.Evaluate(input) - input = "" - } - } - } -} - -// readLines runs in its own goroutine, prompting for input. -func (c *Console) readLines(input chan<- string, errc chan<- error, prompt <-chan string) { - for p := range prompt { - line, err := c.prompter.PromptInput(p) - if err != nil { - errc <- err - } else { - input <- line - } - } -} - -// countIndents returns the number of identations for the given input. -// In case of invalid input such as var a = } the result can be negative. -func countIndents(input string) int { - var ( - indents = 0 - inString = false - strOpenChar = ' ' // keep track of the string open char to allow var str = "I'm ...."; - charEscaped = false // keep track if the previous char was the '\' char, allow var str = "abc\"def"; - ) - - for _, c := range input { - switch c { - case '\\': - // indicate next char as escaped when in string and previous char isn't escaping this backslash - if !charEscaped && inString { - charEscaped = true - } - case '\'', '"': - if inString && !charEscaped && strOpenChar == c { // end string - inString = false - } else if !inString && !charEscaped { // begin string - inString = true - strOpenChar = c - } - charEscaped = false - case '{', '(': - if !inString { // ignore brackets when in string, allow var str = "a{"; without indenting - indents++ - } - charEscaped = false - case '}', ')': - if !inString { - indents-- - } - charEscaped = false - default: - charEscaped = false - } - } - - return indents -} - -// Execute runs the JavaScript file specified as the argument. -func (c *Console) Execute(path string) error { - return c.jsre.Exec(path) -} - -// Stop cleans up the console and terminates the runtime environment. -func (c *Console) Stop(graceful bool) error { - if err := ioutil.WriteFile(c.histPath, []byte(strings.Join(c.history, "\n")), 0600); err != nil { - return err - } - if err := os.Chmod(c.histPath, 0600); err != nil { // Force 0600, even if it was different previously - return err - } - c.jsre.Stop(graceful) - return nil -} diff --git a/console/console_test.go b/console/console_test.go deleted file mode 100644 index 645043548..000000000 --- a/console/console_test.go +++ /dev/null @@ -1,346 +0,0 @@ -// Copyright 2015 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 . - -package console - -import ( - "bytes" - "errors" - "fmt" - "io/ioutil" - "os" - "strings" - "testing" - "time" - - "github.com/davecgh/go-spew/spew" - - "github.com/ledgerwatch/turbo-geth/common" - "github.com/ledgerwatch/turbo-geth/consensus/ethash" - "github.com/ledgerwatch/turbo-geth/console/prompt" - "github.com/ledgerwatch/turbo-geth/core" - "github.com/ledgerwatch/turbo-geth/eth" - "github.com/ledgerwatch/turbo-geth/internal/jsre" - "github.com/ledgerwatch/turbo-geth/miner" - "github.com/ledgerwatch/turbo-geth/node" -) - -const ( - testInstance = "console-tester" - testAddress = "0x8605cdbbdb6d264aa742e77020dcbc58fcdce182" -) - -// hookedPrompter implements UserPrompter to simulate use input via channels. -type hookedPrompter struct { - scheduler chan string -} - -func (p *hookedPrompter) PromptInput(prompt string) (string, error) { - // Send the prompt to the tester - select { - case p.scheduler <- prompt: - case <-time.After(time.Second): - return "", errors.New("prompt timeout") - } - // Retrieve the response and feed to the console - select { - case input := <-p.scheduler: - return input, nil - case <-time.After(time.Second): - return "", errors.New("input timeout") - } -} - -func (p *hookedPrompter) PromptPassword(prompt string) (string, error) { - return "", errors.New("not implemented") -} -func (p *hookedPrompter) PromptConfirm(prompt string) (bool, error) { - return false, errors.New("not implemented") -} -func (p *hookedPrompter) SetHistory(history []string) {} -func (p *hookedPrompter) AppendHistory(command string) {} -func (p *hookedPrompter) ClearHistory() {} -func (p *hookedPrompter) SetWordCompleter(completer prompt.WordCompleter) {} - -// tester is a console test environment for the console tests to operate on. -type tester struct { - workspace string - stack *node.Node - ethereum *eth.Ethereum - console *Console - input *hookedPrompter - output *bytes.Buffer -} - -// newTester creates a test environment based on which the console can operate. -// Please ensure you call Close() on the returned tester to avoid leaks. -func newTester(t *testing.T, confOverride func(*eth.Config)) *tester { - t.Helper() - // Create a temporary storage for the node keys and initialize it - workspace, err := ioutil.TempDir("", "console-tester-") - if err != nil { - t.Fatalf("failed to create temporary keystore: %v", err) - } - - // Create a networkless protocol stack and start an Ethereum service within - stack, err := node.New(&node.Config{DataDir: workspace, UseLightweightKDF: true, Name: testInstance}) - if err != nil { - t.Fatalf("failed to create node: %v", err) - } - ethConf := ð.Config{ - Genesis: core.DeveloperGenesisBlock(15, common.Address{}), - Miner: miner.Config{ - Etherbase: common.HexToAddress(testAddress), - }, - Ethash: ethash.Config{ - PowMode: ethash.ModeTest, - }, - Pruning: false, - } - spew.Dump(ethConf) - if confOverride != nil { - confOverride(ethConf) - } - ethBackend, err := eth.New(stack, ethConf) - if err != nil { - t.Fatalf("failed to register Ethereum protocol: %v", err) - } - // Start the node and assemble the JavaScript console around it - if err = stack.Start(); err != nil { - t.Fatalf("failed to start test stack: %v", err) - } - client, err := stack.Attach() - if err != nil { - t.Fatalf("failed to attach to node: %v", err) - } - prompter := &hookedPrompter{scheduler: make(chan string)} - printer := new(bytes.Buffer) - - console, err := New(Config{ - DataDir: stack.DataDir(), - DocRoot: "testdata", - Client: client, - Prompter: prompter, - Printer: printer, - Preload: []string{"preload.js"}, - }) - if err != nil { - t.Fatalf("failed to create JavaScript console: %v", err) - } - // Create the final tester and return - return &tester{ - workspace: workspace, - stack: stack, - ethereum: ethBackend, - console: console, - input: prompter, - output: printer, - } -} - -// Close cleans up any temporary data folders and held resources. -func (env *tester) Close(t *testing.T) { - if err := env.console.Stop(false); err != nil { - t.Errorf("failed to stop embedded console: %v", err) - } - if err := env.stack.Close(); err != nil { - t.Errorf("failed to tear down embedded node: %v", err) - } - os.RemoveAll(env.workspace) -} - -// Tests that the node lists the correct welcome message, notably that it contains -// the instance name, coinbase account, block number, data directory and supported -// console modules. -func TestWelcome(t *testing.T) { - tester := newTester(t, nil) - defer tester.Close(t) - - tester.console.Welcome() - - output := tester.output.String() - if want := "Welcome"; !strings.Contains(output, want) { - t.Fatalf("console output missing welcome message: have\n%s\nwant also %s", output, want) - } - if want := fmt.Sprintf("instance: %s", testInstance); !strings.Contains(output, want) { - t.Fatalf("console output missing instance: have\n%s\nwant also %s", output, want) - } - //if want := fmt.Sprintf("coinbase: %s", testAddress); !strings.Contains(output, want) { - // t.Fatalf("console output missing coinbase: have\n%s\nwant also %s", output, want) - //} - if want := "at block: 0"; !strings.Contains(output, want) { - t.Fatalf("console output missing sync status: have\n%s\nwant also %s", output, want) - } - if want := fmt.Sprintf("datadir: %s", tester.workspace); !strings.Contains(output, want) { - t.Fatalf("console output missing coinbase: have\n%s\nwant also %s", output, want) - } -} - -// Tests that JavaScript statement evaluation works as intended. -func TestEvaluate(t *testing.T) { - tester := newTester(t, nil) - defer tester.Close(t) - - tester.console.Evaluate("2 + 2") - if output := tester.output.String(); !strings.Contains(output, "4") { - t.Fatalf("statement evaluation failed: have %s, want %s", output, "4") - } -} - -// Tests that the console can be used in interactive mode. -func TestInteractive(t *testing.T) { - // Create a tester and run an interactive console in the background - tester := newTester(t, nil) - defer tester.Close(t) - - go tester.console.Interactive() - - // Wait for a prompt and send a statement back - select { - case <-tester.input.scheduler: - case <-time.After(time.Second): - t.Fatalf("initial prompt timeout") - } - select { - case tester.input.scheduler <- "2+2": - case <-time.After(time.Second): - t.Fatalf("input feedback timeout") - } - // Wait for the second prompt and ensure first statement was evaluated - select { - case <-tester.input.scheduler: - case <-time.After(time.Second): - t.Fatalf("secondary prompt timeout") - } - if output := tester.output.String(); !strings.Contains(output, "4") { - t.Fatalf("statement evaluation failed: have %s, want %s", output, "4") - } -} - -// Tests that preloaded JavaScript files have been executed before user is given -// input. -func TestPreload(t *testing.T) { - tester := newTester(t, nil) - defer tester.Close(t) - - tester.console.Evaluate("preloaded") - if output := tester.output.String(); !strings.Contains(output, "some-preloaded-string") { - t.Fatalf("preloaded variable missing: have %s, want %s", output, "some-preloaded-string") - } -} - -// Tests that JavaScript scripts can be executes from the configured asset path. -func TestExecute(t *testing.T) { - tester := newTester(t, nil) - defer tester.Close(t) - - tester.console.Execute("exec.js") - - tester.console.Evaluate("execed") - if output := tester.output.String(); !strings.Contains(output, "some-executed-string") { - t.Fatalf("execed variable missing: have %s, want %s", output, "some-executed-string") - } -} - -// Tests that the JavaScript objects returned by statement executions are properly -// pretty printed instead of just displaying "[object]". -func TestPrettyPrint(t *testing.T) { - tester := newTester(t, nil) - defer tester.Close(t) - - tester.console.Evaluate("obj = {int: 1, string: 'two', list: [3, 3, 3], obj: {null: null, func: function(){}}}") - - // Define some specially formatted fields - var ( - one = jsre.NumberColor("1") - two = jsre.StringColor("\"two\"") - three = jsre.NumberColor("3") - null = jsre.SpecialColor("null") - fun = jsre.FunctionColor("function()") - ) - // Assemble the actual output we're after and verify - want := `{ - int: ` + one + `, - list: [` + three + `, ` + three + `, ` + three + `], - obj: { - null: ` + null + `, - func: ` + fun + ` - }, - string: ` + two + ` -} -` - if output := tester.output.String(); output != want { - t.Fatalf("pretty print mismatch: have %s, want %s", output, want) - } -} - -// Tests that the JavaScript exceptions are properly formatted and colored. -func TestPrettyError(t *testing.T) { - tester := newTester(t, nil) - defer tester.Close(t) - tester.console.Evaluate("throw 'hello'") - - want := jsre.ErrorColor("hello") + "\n\tat :1:7(1)\n\n" - if output := tester.output.String(); output != want { - t.Fatalf("pretty error mismatch: have %s, want %s", output, want) - } -} - -// Tests that tests if the number of indents for JS input is calculated correct. -func TestIndenting(t *testing.T) { - testCases := []struct { - input string - expectedIndentCount int - }{ - {`var a = 1;`, 0}, - {`"some string"`, 0}, - {`"some string with (parenthesis`, 0}, - {`"some string with newline - ("`, 0}, - {`function v(a,b) {}`, 0}, - {`function f(a,b) { var str = "asd("; };`, 0}, - {`function f(a) {`, 1}, - {`function f(a, function(b) {`, 2}, - {`function f(a, function(b) { - var str = "a)}"; - });`, 0}, - {`function f(a,b) { - var str = "a{b(" + a, ", " + b; - }`, 0}, - {`var str = "\"{"`, 0}, - {`var str = "'("`, 0}, - {`var str = "\\{"`, 0}, - {`var str = "\\\\{"`, 0}, - {`var str = 'a"{`, 0}, - {`var obj = {`, 1}, - {`var obj = { {a:1`, 2}, - {`var obj = { {a:1}`, 1}, - {`var obj = { {a:1}, b:2}`, 0}, - {`var obj = {}`, 0}, - {`var obj = { - a: 1, b: 2 - }`, 0}, - {`var test = }`, -1}, - {`var str = "a\""; var obj = {`, 1}, - } - - for i, tt := range testCases { - counted := countIndents(tt.input) - if counted != tt.expectedIndentCount { - t.Errorf("test %d: invalid indenting: have %d, want %d", i, counted, tt.expectedIndentCount) - } - } -} diff --git a/console/testdata/exec.js b/console/testdata/exec.js deleted file mode 100644 index 59e34d7c4..000000000 --- a/console/testdata/exec.js +++ /dev/null @@ -1 +0,0 @@ -var execed = "some-executed-string"; diff --git a/console/testdata/preload.js b/console/testdata/preload.js deleted file mode 100644 index 556793970..000000000 --- a/console/testdata/preload.js +++ /dev/null @@ -1 +0,0 @@ -var preloaded = "some-preloaded-string"; diff --git a/node/api.go b/node/api.go index 8d0a25b48..48ecbe569 100644 --- a/node/api.go +++ b/node/api.go @@ -207,7 +207,7 @@ func (api *privateAdminAPI) StartRPC(host *string, port *int, cors *string, apis if err := api.node.http.setListenAddr(*host, *port); err != nil { return false, err } - if err := api.node.http.enableRPC(api.node.rpcAPIs, config); err != nil { + if err := api.node.http.enableRPC(api.node.rpcAPIs, config, nil); err != nil { return false, err } if err := api.node.http.start(); err != nil { @@ -263,7 +263,7 @@ func (api *privateAdminAPI) StartWS(host *string, port *int, allowedOrigins *str if err := server.setListenAddr(*host, *port); err != nil { return false, err } - if err := server.enableWS(api.node.rpcAPIs, config); err != nil { + if err := server.enableWS(api.node.rpcAPIs, config, nil); err != nil { return false, err } if err := server.start(); err != nil { diff --git a/node/node.go b/node/node.go index 956af6b5e..d02e5fc7a 100644 --- a/node/node.go +++ b/node/node.go @@ -58,6 +58,8 @@ type Node struct { ipc *ipcServer // Stores information about the ipc http server inprocHandler *rpc.Server // In-process RPC request handler to process the API requests + rpcAllowList rpc.AllowList // list of RPC methods explicitly allowed for this RPC node + databases []ethdb.Closer } @@ -342,6 +344,11 @@ func (n *Node) closeDataDir() { } } +// SetAllowListForRPC sets granular allow list for exposed RPC methods +func (n *Node) SetAllowListForRPC(allowList rpc.AllowList) { + n.rpcAllowList = allowList +} + // configureRPC is a helper method to configure all the various RPC endpoints during node // startup. It's not meant to be called at any time afterwards as it makes certain // assumptions about the state of the node. @@ -367,7 +374,7 @@ func (n *Node) startRPC() error { if err := n.http.setListenAddr(n.config.HTTPHost, n.config.HTTPPort); err != nil { return err } - if err := n.http.enableRPC(n.rpcAPIs, config); err != nil { + if err := n.http.enableRPC(n.rpcAPIs, config, n.rpcAllowList); err != nil { return err } } @@ -382,7 +389,7 @@ func (n *Node) startRPC() error { if err := server.setListenAddr(n.config.WSHost, n.config.WSPort); err != nil { return err } - if err := server.enableWS(n.rpcAPIs, config); err != nil { + if err := server.enableWS(n.rpcAPIs, config, n.rpcAllowList); err != nil { return err } } diff --git a/node/node_test.go b/node/node_test.go index b5a0e62fe..c6f5304b7 100644 --- a/node/node_test.go +++ b/node/node_test.go @@ -484,7 +484,6 @@ func TestWebsocketHTTPOnSeparatePort_WSRequest(t *testing.T) { if !checkRPC(node.HTTPEndpoint()) { t.Fatalf("http request failed") } - } func createNode(t *testing.T, httpPort, wsPort int) *Node { diff --git a/node/rpcstack.go b/node/rpcstack.go index 2ee3b42ea..a78c0afcf 100644 --- a/node/rpcstack.go +++ b/node/rpcstack.go @@ -227,7 +227,7 @@ func (h *httpServer) doStop() { } // enableRPC turns on JSON-RPC over HTTP on the server. -func (h *httpServer) enableRPC(apis []rpc.API, config httpConfig) error { +func (h *httpServer) enableRPC(apis []rpc.API, config httpConfig, allowList rpc.AllowList) error { h.mu.Lock() defer h.mu.Unlock() @@ -237,6 +237,7 @@ func (h *httpServer) enableRPC(apis []rpc.API, config httpConfig) error { // Create RPC server and handler. srv := rpc.NewServer() + srv.SetAllowList(allowList) if err := RegisterApisFromWhitelist(apis, config.Modules, srv, false); err != nil { return err } @@ -259,7 +260,7 @@ func (h *httpServer) disableRPC() bool { } // enableWS turns on JSON-RPC over WebSocket on the server. -func (h *httpServer) enableWS(apis []rpc.API, config wsConfig) error { +func (h *httpServer) enableWS(apis []rpc.API, config wsConfig, allowList rpc.AllowList) error { h.mu.Lock() defer h.mu.Unlock() @@ -269,6 +270,7 @@ func (h *httpServer) enableWS(apis []rpc.API, config wsConfig) error { // Create RPC server and handler. srv := rpc.NewServer() + srv.SetAllowList(allowList) if err := RegisterApisFromWhitelist(apis, config.Modules, srv, false); err != nil { return err } diff --git a/node/rpcstack_test.go b/node/rpcstack_test.go index 82fdd5f40..b374ea068 100644 --- a/node/rpcstack_test.go +++ b/node/rpcstack_test.go @@ -18,7 +18,10 @@ package node import ( "bytes" + "fmt" + "io/ioutil" "net/http" + "strings" "testing" "github.com/ledgerwatch/turbo-geth/internal/testlog" @@ -89,14 +92,16 @@ func TestIsWebsocket(t *testing.T) { assert.True(t, isWebsocket(r)) } -func createAndStartServer(t *testing.T, conf httpConfig, ws bool, wsConf wsConfig) *httpServer { +func createAndStartServerWithAllowList(t *testing.T, conf httpConfig, ws bool, wsConf wsConfig) *httpServer { t.Helper() srv := newHTTPServer(testlog.Logger(t, log.LvlDebug), rpc.DefaultHTTPTimeouts) - assert.NoError(t, srv.enableRPC(nil, conf)) + allowList := rpc.AllowList(map[string]struct{}{"net_version": {}}) //don't allow RPC modules + + assert.NoError(t, srv.enableRPC(nil, conf, allowList)) if ws { - assert.NoError(t, srv.enableWS(nil, wsConf)) + assert.NoError(t, srv.enableWS(nil, wsConf, allowList)) } assert.NoError(t, srv.setListenAddr("localhost", 0)) assert.NoError(t, srv.start()) @@ -104,10 +109,48 @@ func createAndStartServer(t *testing.T, conf httpConfig, ws bool, wsConf wsConfi return srv } +func createAndStartServer(t *testing.T, conf httpConfig, ws bool, wsConf wsConfig) *httpServer { + t.Helper() + + srv := newHTTPServer(testlog.Logger(t, log.LvlDebug), rpc.DefaultHTTPTimeouts) + + assert.NoError(t, srv.enableRPC(nil, conf, nil)) + if ws { + assert.NoError(t, srv.enableWS(nil, wsConf, nil)) + } + assert.NoError(t, srv.setListenAddr("localhost", 0)) + assert.NoError(t, srv.start()) + + return srv +} + +func TestAllowList(t *testing.T) { + srv := createAndStartServerWithAllowList(t, httpConfig{}, false, wsConfig{}) + defer srv.stop() + + assert.False(t, testCustomRequest(t, srv, "rpc_modules")) +} + +func testCustomRequest(t *testing.T, srv *httpServer, method string) bool { + body := bytes.NewReader([]byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"%s"}`, method))) + req, _ := http.NewRequest("POST", "http://"+srv.listenAddr(), body) + req.Header.Set("content-type", "application/json") + + client := http.DefaultClient + resp, err := client.Do(req) + if err != nil { + return false + } + respBody, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err) + + return !strings.Contains(string(respBody), "error") +} + func testRequest(t *testing.T, key, value, host string, srv *httpServer) *http.Response { t.Helper() - body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,method":"rpc_modules"}`)) + body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"rpc_modules"}`)) req, _ := http.NewRequest("POST", "http://"+srv.listenAddr(), body) req.Header.Set("content-type", "application/json") if key != "" && value != "" { diff --git a/rpc/allow_list.go b/rpc/allow_list.go new file mode 100644 index 000000000..c4da4cd02 --- /dev/null +++ b/rpc/allow_list.go @@ -0,0 +1,35 @@ +package rpc + +import "encoding/json" + +type AllowList map[string]struct{} + +func (a *AllowList) UnmarshalJSON(data []byte) error { + var keys []string + err := json.Unmarshal(data, &keys) + if err != nil { + return err + } + + realA := make(map[string]struct{}) + + for _, k := range keys { + realA[k] = struct{}{} + } + + *a = realA + + return nil +} + +// MarshalJSON returns *m as the JSON encoding of +func (a *AllowList) MarshalJSON() ([]byte, error) { + var realA map[string]struct{} = *a + keys := make([]string, len(realA)) + i := 0 + for key := range realA { + keys[i] = key + i++ + } + return json.Marshal(keys) +} diff --git a/rpc/allow_list_test.go b/rpc/allow_list_test.go new file mode 100644 index 000000000..988b811fb --- /dev/null +++ b/rpc/allow_list_test.go @@ -0,0 +1,23 @@ +package rpc + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAllowListMarshaling(t *testing.T) { + +} + +func TestAllowListUnmarshaling(t *testing.T) { + allowListJSON := `[ "one", "two", "three" ]` + + var allowList AllowList + err := json.Unmarshal([]byte(allowListJSON), &allowList) + assert.NoError(t, err, "should unmarshal successfully") + + m := map[string]struct{}{"one": {}, "two": {}, "three": {}} + assert.Equal(t, allowList, AllowList(m)) +} diff --git a/rpc/client.go b/rpc/client.go index b936f80c5..498f0bcff 100644 --- a/rpc/client.go +++ b/rpc/client.go @@ -74,9 +74,10 @@ type BatchElem struct { // Client represents a connection to an RPC server. type Client struct { - idgen func() ID // for subscriptions - isHTTP bool - services *serviceRegistry + idgen func() ID // for subscriptions + isHTTP bool + services *serviceRegistry + methodAllowList AllowList idCounter uint32 @@ -111,7 +112,7 @@ type clientConn struct { func (c *Client) newClientConn(conn ServerCodec) *clientConn { ctx := context.WithValue(context.Background(), clientContextKey{}, c) - handler := newHandler(ctx, conn, c.idgen, c.services) + handler := newHandler(ctx, conn, c.idgen, c.services, c.methodAllowList) return &clientConn{conn, handler} } diff --git a/rpc/handler.go b/rpc/handler.go index b927f0236..12fe0f82e 100644 --- a/rpc/handler.go +++ b/rpc/handler.go @@ -62,6 +62,8 @@ type handler struct { log log.Logger allowSubscribe bool + allowList AllowList // a list of explicitly allowed methods, if empty -- everything is allowed + subLock sync.Mutex serverSubs map[ID]*Subscription } @@ -71,7 +73,7 @@ type callProc struct { notifiers []*Notifier } -func newHandler(connCtx context.Context, conn jsonWriter, idgen func() ID, reg *serviceRegistry) *handler { +func newHandler(connCtx context.Context, conn jsonWriter, idgen func() ID, reg *serviceRegistry, allowList AllowList) *handler { rootCtx, cancelRoot := context.WithCancel(connCtx) h := &handler{ reg: reg, @@ -84,6 +86,7 @@ func newHandler(connCtx context.Context, conn jsonWriter, idgen func() ID, reg * allowSubscribe: true, serverSubs: make(map[ID]*Subscription), log: log.Root(), + allowList: allowList, } if conn.remoteAddr() != "" { h.log = h.log.New("conn", conn.remoteAddr()) @@ -314,6 +317,14 @@ func (h *handler) handleCallMsg(ctx *callProc, msg *jsonrpcMessage) *jsonrpcMess } } +func (h *handler) isMethodAllowedByGranularControl(method string) bool { + if len(h.allowList) == 0 { + return true + } + _, ok := h.allowList[method] + return ok +} + // handleCall processes method calls. func (h *handler) handleCall(cp *callProc, msg *jsonrpcMessage) *jsonrpcMessage { if msg.isSubscribe() { @@ -322,7 +333,7 @@ func (h *handler) handleCall(cp *callProc, msg *jsonrpcMessage) *jsonrpcMessage var callb *callback if msg.isUnsubscribe() { callb = h.unsubscribeCb - } else { + } else if h.isMethodAllowedByGranularControl(msg.Method) { callb = h.reg.callback(msg.Method) } if callb == nil { diff --git a/rpc/server.go b/rpc/server.go index ac9f29f91..495c14d78 100644 --- a/rpc/server.go +++ b/rpc/server.go @@ -42,10 +42,11 @@ const ( // Server is an RPC server. type Server struct { - services serviceRegistry - idgen func() ID - run int32 - codecs mapset.Set + services serviceRegistry + methodAllowList AllowList + idgen func() ID + run int32 + codecs mapset.Set } // NewServer creates a new server instance with no registered handlers. @@ -58,6 +59,11 @@ func NewServer() *Server { return server } +// SetAllowList sets the allow list for methods that are handled by this server +func (s *Server) SetAllowList(allowList AllowList) { + s.methodAllowList = allowList +} + // RegisterName creates a service for the given receiver type under the given name. When no // methods on the given receiver match the criteria to be either a RPC method or a // subscription an error is returned. Otherwise a new service is created and added to the @@ -97,7 +103,7 @@ func (s *Server) serveSingleRequest(ctx context.Context, codec ServerCodec) { return } - h := newHandler(ctx, codec, s.idgen, &s.services) + h := newHandler(ctx, codec, s.idgen, &s.services, s.methodAllowList) h.allowSubscribe = false defer h.close(io.EOF, nil)