diff --git a/cmd/geth/config.go b/cmd/geth/config.go index c02e307bdc..0bdedd5b3a 100644 --- a/cmd/geth/config.go +++ b/cmd/geth/config.go @@ -269,6 +269,10 @@ func makeFullNode(ctx *cli.Context) *node.Node { // Configure log filter RPC API. filterSystem := utils.RegisterFilterAPI(stack, backend, &cfg.Eth) + // Configure the healthcheck API if requested. + if ctx.IsSet(utils.HTTPHealthEnabledFlag.Name) { + utils.RegisterHealthService(stack, &cfg.Node) + } // Configure GraphQL if requested. if ctx.Bool(utils.GraphQLEnabledFlag.Name) { utils.RegisterGraphQLService(stack, backend, filterSystem, &cfg.Node) diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 850e26d161..9435b2fdea 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -164,6 +164,7 @@ var ( utils.HTTPListenAddrFlag, utils.HTTPPortFlag, utils.HTTPCORSDomainFlag, + utils.HTTPHealthEnabledFlag, utils.AuthListenFlag, utils.AuthPortFlag, utils.AuthVirtualHostsFlag, diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index ea0f6f5ee4..33188603f6 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -56,6 +56,7 @@ import ( "github.com/ethereum/go-ethereum/ethdb/remotedb" "github.com/ethereum/go-ethereum/ethstats" "github.com/ethereum/go-ethereum/graphql" + "github.com/ethereum/go-ethereum/health" "github.com/ethereum/go-ethereum/internal/ethapi" "github.com/ethereum/go-ethereum/internal/flags" "github.com/ethereum/go-ethereum/log" @@ -771,6 +772,11 @@ var ( Value: "", Category: flags.APICategory, } + HTTPHealthEnabledFlag = &cli.BoolFlag{ + Name: "http.health", + Usage: "Enable the HTTP healthcheck API at path '/health'.", + Category: flags.APICategory, + } GraphQLEnabledFlag = &cli.BoolFlag{ Name: "graphql", Usage: "Enable GraphQL on the HTTP-RPC server. Note that GraphQL can only be started if an HTTP server is started as well.", @@ -2222,6 +2228,14 @@ func RegisterGraphQLService(stack *node.Node, backend ethapi.Backend, filterSyst } } +// RegisterHealthService adds the Health API to the node. +func RegisterHealthService(stack *node.Node, cfg *node.Config) { + err := health.New(stack, cfg.HTTPCors, cfg.HTTPVirtualHosts) + if err != nil { + Fatalf("Failed to register the health service: %v", err) + } +} + // RegisterFilterAPI adds the eth log filtering RPC API to the node. func RegisterFilterAPI(stack *node.Node, backend ethapi.Backend, ethcfg *ethconfig.Config) *filters.FilterSystem { filterSystem := filters.NewFilterSystem(backend, filters.Config{ diff --git a/health/check_block.go b/health/check_block.go new file mode 100644 index 0000000000..cea10738e8 --- /dev/null +++ b/health/check_block.go @@ -0,0 +1,15 @@ +package health + +import ( + "context" + "math/big" +) + +// checkBlockNumber confirms this node is aware of a specific block. +func checkBlockNumber(ec ethClient, blockNumber *big.Int) error { + _, err := ec.BlockByNumber(context.TODO(), blockNumber) + if err != nil { + return err + } + return nil +} diff --git a/health/check_peers.go b/health/check_peers.go new file mode 100644 index 0000000000..b07cebd429 --- /dev/null +++ b/health/check_peers.go @@ -0,0 +1,23 @@ +package health + +import ( + "context" + "errors" + "fmt" +) + +var ( + errNotEnoughPeers = errors.New("not enough peers") +) + +// checkMinPeers returns 'errNotEnoughPeers' if the current peer count its lower than 'minPeerCount' +func checkMinPeers(ec ethClient, minPeerCount uint64) error { + peerCount, err := ec.PeerCount(context.TODO()) + if err != nil { + return err + } + if peerCount < minPeerCount { + return fmt.Errorf("%w: %d (minimum %d)", errNotEnoughPeers, peerCount, minPeerCount) + } + return nil +} diff --git a/health/check_synced.go b/health/check_synced.go new file mode 100644 index 0000000000..2a06e769f2 --- /dev/null +++ b/health/check_synced.go @@ -0,0 +1,27 @@ +package health + +import ( + "context" + "errors" + "net/http" + + "github.com/ethereum/go-ethereum/log" +) + +var ( + errNotSynced = errors.New("not synced") +) + +// checkSynced returns 'errNotSynced' if the node is in the syncing state. +func checkSynced(ec ethClient, r *http.Request) error { + i, err := ec.SyncProgress(context.TODO()) + if err != nil { + log.Root().Warn("Unable to check sync status for healthcheck", "err", err.Error()) + return err + } + if i == nil { + return nil + } + + return errNotSynced +} diff --git a/health/check_time.go b/health/check_time.go new file mode 100644 index 0000000000..dab5736223 --- /dev/null +++ b/health/check_time.go @@ -0,0 +1,30 @@ +package health + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +var ( + errTimestampTooOld = errors.New("timestamp too old") +) + +// checkTime fetches the timestamp of the most recent block and returns an error if it is earlier than 'minTimestamp'. +func checkTime( + ec ethClient, + r *http.Request, + minTimestamp int, +) error { + i, err := ec.BlockByNumber(context.TODO(), nil) + if err != nil { + return err + } + timestamp := i.Time() + if timestamp < uint64(minTimestamp) { + return fmt.Errorf("%w: got ts: %d, need: %d", errTimestampTooOld, timestamp, minTimestamp) + } + + return nil +} diff --git a/health/health.go b/health/health.go new file mode 100644 index 0000000000..aff8f34540 --- /dev/null +++ b/health/health.go @@ -0,0 +1,211 @@ +package health + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/log" +) + +const ( + healthHeader = "X-GETH-HEALTHCHECK" + query = "query" + synced = "synced" + minPeerCount = "min_peer_count" + checkBlock = "check_block" + maxSecondsBehind = "max_seconds_behind" +) + +var ( + errCheckDisabled = errors.New("error check disabled") + errInvalidValue = errors.New("invalid value provided") +) + +type requestBody struct { + Synced *bool `json:"synced"` + MinPeerCount *uint `json:"min_peer_count"` + CheckBlock *big.Int `json:"check_block"` + MaxSecondsBehind *int `json:"max_seconds_behind"` +} + +// processFromHeaders handles requests when 'X-GETH-HEALTHCHECK' header labels are present. +func processFromHeaders(ec ethClient, headers []string, w http.ResponseWriter, r *http.Request) { + var ( + errCheckSynced = errCheckDisabled + errCheckPeer = errCheckDisabled + errCheckBlock = errCheckDisabled + errCheckSeconds = errCheckDisabled + ) + + for _, header := range headers { + lHeader := strings.ToLower(header) + switch { + case lHeader == synced: + errCheckSynced = checkSynced(ec, r) + case strings.HasPrefix(lHeader, minPeerCount): + peers, err := strconv.Atoi(strings.TrimPrefix(lHeader, minPeerCount)) + if err != nil { + errCheckPeer = err + break + } + errCheckPeer = checkMinPeers(ec, uint64(peers)) + case strings.HasPrefix(lHeader, checkBlock): + block, err := strconv.Atoi(strings.TrimPrefix(lHeader, checkBlock)) + if err != nil { + errCheckBlock = err + break + } + errCheckBlock = checkBlockNumber(ec, big.NewInt(int64(block))) + case strings.HasPrefix(lHeader, maxSecondsBehind): + seconds, err := strconv.Atoi(strings.TrimPrefix(lHeader, maxSecondsBehind)) + if err != nil { + errCheckSeconds = err + break + } + if seconds < 0 { + errCheckSeconds = errInvalidValue + break + } + now := time.Now().Unix() + errCheckSeconds = checkTime(ec, r, int(now)-seconds) + } + } + + reportHealth(nil, errCheckSynced, errCheckPeer, errCheckBlock, errCheckSeconds, w) +} + +// processFromBody handles requests when 'X-GETH-HEALTHCHECK' headers are not present. +func processFromBody(ec ethClient, w http.ResponseWriter, r *http.Request) { + body, errParse := parseHealthCheckBody(r.Body) + defer r.Body.Close() + + var ( + errCheckSynced = errCheckDisabled + errCheckPeer = errCheckDisabled + errCheckBlock = errCheckDisabled + errCheckSeconds = errCheckDisabled + ) + + if errParse != nil { + log.Root().Warn("Unable to process healthcheck request", "err", errParse) + } else { + if body.Synced != nil { + errCheckSynced = checkSynced(ec, r) + } + + if body.MinPeerCount != nil { + errCheckPeer = checkMinPeers(ec, uint64(*body.MinPeerCount)) + } + + if body.CheckBlock != nil { + errCheckBlock = checkBlockNumber(ec, body.CheckBlock) + } + + if body.MaxSecondsBehind != nil { + seconds := *body.MaxSecondsBehind + if seconds < 0 { + errCheckSeconds = errInvalidValue + } else { + now := time.Now().Unix() + errCheckSeconds = checkTime(ec, r, int(now)-seconds) + } + } + } + + err := reportHealth(errParse, errCheckSynced, errCheckPeer, errCheckBlock, errCheckSeconds, w) + if err != nil { + log.Root().Warn("Unable to process healthcheck request", "err", err) + } +} + +// reportHealth builds the response body, sets the status code and calls for it to be written. +func reportHealth(errParse, errCheckSynced, errCheckPeer, errCheckBlock, errCheckSeconds error, w http.ResponseWriter) error { + statusCode := http.StatusOK + errs := make(map[string]string) + + if shouldChangeStatusCode(errParse) { + statusCode = http.StatusInternalServerError + } + errs[query] = errorStringOrOK(errParse) + + if shouldChangeStatusCode(errCheckSynced) { + statusCode = http.StatusInternalServerError + } + errs[synced] = errorStringOrOK(errCheckSynced) + + if shouldChangeStatusCode(errCheckPeer) { + statusCode = http.StatusInternalServerError + } + errs[minPeerCount] = errorStringOrOK(errCheckPeer) + + if shouldChangeStatusCode(errCheckBlock) { + statusCode = http.StatusInternalServerError + } + errs[checkBlock] = errorStringOrOK(errCheckBlock) + + if shouldChangeStatusCode(errCheckSeconds) { + statusCode = http.StatusInternalServerError + } + errs[maxSecondsBehind] = errorStringOrOK(errCheckSeconds) + + return writeResponse(w, errs, statusCode) +} + +// parseHealthCheckBody parses and type checks the request body when 'X-GETH-HEALTHCHECK' headers are not present. +func parseHealthCheckBody(reader io.Reader) (requestBody, error) { + var body requestBody + + bodyBytes, err := io.ReadAll(reader) + if err != nil { + return body, err + } + + err = json.Unmarshal(bodyBytes, &body) + if err != nil { + return body, err + } + + return body, nil +} + +// writeResponse delivers the status and body to the response writer. +func writeResponse(w http.ResponseWriter, errs map[string]string, statusCode int) error { + w.WriteHeader(statusCode) + + bodyJson, err := json.Marshal(errs) + if err != nil { + return err + } + + _, err = w.Write(bodyJson) + if err != nil { + return err + } + + return nil +} + +// shouldChangeStatusCode returns 'true' if an error exists and is not 'errCheckDisabled'. +func shouldChangeStatusCode(err error) bool { + return err != nil && !errors.Is(err, errCheckDisabled) +} + +// errorStringOrOK returns "OK", "DISABLED" or the error message based on the output of the check. +func errorStringOrOK(err error) string { + if err == nil { + return "OK" + } + + if errors.Is(err, errCheckDisabled) { + return "DISABLED" + } + + return fmt.Sprintf("ERROR: %v", err) +} diff --git a/health/health_test.go b/health/health_test.go new file mode 100644 index 0000000000..f1422d789c --- /dev/null +++ b/health/health_test.go @@ -0,0 +1,742 @@ +package health + +import ( + "context" + "encoding/json" + "errors" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/core/types" +) + +type ethClientStub struct { + peersResult uint64 + peersError error + blockResult *types.Block + blockError error + syncingResult *ethereum.SyncProgress + syncingError error +} + +func (e *ethClientStub) PeerCount(_ context.Context) (uint64, error) { + return e.peersResult, e.peersError +} + +func (e *ethClientStub) BlockByNumber(_ context.Context, _ *big.Int) (*types.Block, error) { + return e.blockResult, e.blockError +} + +func (e *ethClientStub) SyncProgress(_ context.Context) (*ethereum.SyncProgress, error) { + return e.syncingResult, e.syncingError +} + +func TestProcessFromHeaders(t *testing.T) { + cases := []struct { + headers []string + clientPeerResult uint64 + clientPeerError error + clientBlockResult *types.Block + clientBlockError error + clientSyncingResult *ethereum.SyncProgress + clientSyncingError error + expectedStatusCode int + expectedBody map[string]string + }{ + // 0 - sync check enabled - syncing + { + headers: []string{"synced"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: &types.Block{}, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + synced: "OK", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 1 - sync check enabled - not syncing + { + headers: []string{"synced"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: ðereum.SyncProgress{}, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "ERROR: not synced", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 2 - sync check enabled - error checking sync + { + headers: []string{"synced"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: ðereum.SyncProgress{}, + clientSyncingError: errors.New("problem checking sync"), + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "ERROR: problem checking sync", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 3 - peer count enabled - good request + { + headers: []string{"min_peer_count1"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "OK", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 4 - peer count enabled - not enough peers + { + headers: []string{"min_peer_count10"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "ERROR: not enough peers: 1 (minimum 10)", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 5 - peer count enabled - error checking peers + { + headers: []string{"min_peer_count10"}, + clientPeerResult: uint64(1), + clientPeerError: errors.New("problem checking peers"), + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "ERROR: problem checking peers", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 6 - peer count enabled - badly formed request + { + headers: []string{"min_peer_countABC"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "ERROR: strconv.Atoi: parsing \"abc\": invalid syntax", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 7 - block check - all ok + { + headers: []string{"check_block10"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: &types.Block{}, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "OK", + maxSecondsBehind: "DISABLED", + }, + }, + // 8 - block check - no block found + { + headers: []string{"check_block10"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: errors.New("not found"), + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "ERROR: not found", + maxSecondsBehind: "DISABLED", + }, + }, + // 9 - block check - error checking block + { + headers: []string{"check_block10"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: &types.Block{}, + clientBlockError: errors.New("problem checking block"), + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "ERROR: problem checking block", + maxSecondsBehind: "DISABLED", + }, + }, + // 10 - block check - badly formed request + { + headers: []string{"check_blockABC"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "ERROR: strconv.Atoi: parsing \"abc\": invalid syntax", + maxSecondsBehind: "DISABLED", + }, + }, + // 11 - seconds check - all ok + { + headers: []string{"max_seconds_behind60"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: types.NewBlockWithHeader(&types.Header{ + Time: uint64(time.Now().Add(-10 * time.Second).Unix()), + }), + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "OK", + }, + }, + // 12 - seconds check - too old + { + headers: []string{"max_seconds_behind60"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: types.NewBlockWithHeader(&types.Header{ + Time: uint64(time.Now().Add(-1 * time.Hour).Unix()), + }), + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "ERROR: timestamp too old: got ts:", + }, + }, + // 13 - seconds check - less than 0 seconds + { + headers: []string{"max_seconds_behind-1"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: types.NewBlockWithHeader(&types.Header{ + Time: uint64(time.Now().Add(1 * time.Hour).Unix()), + }), + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "ERROR: invalid value provided", + }, + }, + // 14 - seconds check - badly formed request + { + headers: []string{"max_seconds_behindABC"}, + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: &types.Block{}, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "ERROR: strconv.Atoi: parsing \"abc\": invalid syntax", + }, + }, + // 15 - all checks - report ok + { + headers: []string{"synced", "check_block10", "min_peer_count1", "max_seconds_behind60"}, + clientPeerResult: uint64(10), + clientPeerError: nil, + clientBlockResult: types.NewBlockWithHeader(&types.Header{ + Time: uint64(time.Now().Add(1 * time.Second).Unix()), + }), + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + synced: "OK", + minPeerCount: "OK", + checkBlock: "OK", + maxSecondsBehind: "OK", + }, + }, + } + + for idx, c := range cases { + w := httptest.NewRecorder() + r, err := http.NewRequest(http.MethodGet, "http://localhost:9090/health", nil) + if err != nil { + t.Errorf("%v: creating request: %v", idx, err) + } + + for _, header := range c.headers { + r.Header.Add("X-GETH-HEALTHCHECK", header) + } + + ethClient := ðClientStub{ + peersResult: c.clientPeerResult, + peersError: c.clientPeerError, + blockResult: c.clientBlockResult, + blockError: c.clientBlockError, + syncingResult: c.clientSyncingResult, + syncingError: c.clientSyncingError, + } + + processFromHeaders(ethClient, r.Header.Values(healthHeader), w, r) + + result := w.Result() + if result.StatusCode != c.expectedStatusCode { + t.Errorf("%v: expected status code: %v, but got: %v", idx, c.expectedStatusCode, result.StatusCode) + } + + bodyBytes, err := io.ReadAll(result.Body) + if err != nil { + t.Errorf("%v: reading response body: %s", idx, err) + } + + var body map[string]string + err = json.Unmarshal(bodyBytes, &body) + if err != nil { + t.Errorf("%v: unmarshalling the response body: %s", idx, err) + } + result.Body.Close() + + for k, v := range c.expectedBody { + val, found := body[k] + if !found { + t.Errorf("%v: expected the key: %s to be in the response body but it wasn't there", idx, k) + } + if !strings.Contains(val, v) { + t.Errorf("%v: expected the response body key: %s to contain: %s, but it contained: %s", idx, k, v, val) + } + } + } +} + +func TestProcessFromBody(t *testing.T) { + cases := []struct { + body string + clientPeerResult uint64 + clientPeerError error + clientBlockResult *types.Block + clientBlockError error + clientSyncingResult *ethereum.SyncProgress + clientSyncingError error + expectedStatusCode int + expectedBody map[string]string + }{ + // 0 - sync check enabled - syncing + { + body: "{\"synced\": true}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + query: "OK", + synced: "OK", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 1 - sync check enabled - not syncing + { + body: "{\"synced\": true}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: ðereum.SyncProgress{}, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "OK", + synced: "ERROR: not synced", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 2 - sync check enabled - error checking sync + { + body: "{\"synced\": true}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: ðereum.SyncProgress{}, + clientSyncingError: errors.New("problem checking sync"), + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "OK", + synced: "ERROR: problem checking sync", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 3 - peer count enabled - good request + { + body: "{\"min_peer_count\": 1}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "OK", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 4 - peer count enabled - not enough peers + { + body: "{\"min_peer_count\": 10}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "ERROR: not enough peers: 1 (minimum 10)", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 5 - peer count enabled - error checking peers + { + body: "{\"min_peer_count\": 10}", + clientPeerResult: uint64(1), + clientPeerError: errors.New("problem checking peers"), + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "ERROR: problem checking peers", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 6 - peer count enabled - badly formed request + { + body: "{\"min_peer_count\": \"ABC\"}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "ERROR: json: cannot unmarshal string into Go struct field requestBody.min_peer_count of type uint", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 7 - block check - all ok + { + body: "{\"check_block\": 10}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: &types.Block{}, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "OK", + maxSecondsBehind: "DISABLED", + }, + }, + // 8 - block check - no block found + { + body: "{\"check_block\": 10}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: errors.New("not found"), + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "ERROR: not found", + maxSecondsBehind: "DISABLED", + }, + }, + // 9 - block check - error checking block + { + body: "{\"check_block\": 10}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: &types.Block{}, + clientBlockError: errors.New("problem checking block"), + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "ERROR: problem checking block", + maxSecondsBehind: "DISABLED", + }, + }, + // 10 - block check - badly formed request + { + body: "{\"check_block\": \"ABC\"}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: nil, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "ERROR: math/big: cannot unmarshal \"\\\"ABC\\\"\" into a *big.Int", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 11 - seconds check - all ok + { + body: "{\"max_seconds_behind\": 60}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: types.NewBlockWithHeader(&types.Header{ + Time: uint64(time.Now().Add(-10 * time.Second).Unix()), + }), + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "OK", + }, + }, + // 12 - seconds check - too old + { + body: "{\"max_seconds_behind\": 60}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: types.NewBlockWithHeader(&types.Header{ + Time: uint64(time.Now().Add(-1 * time.Hour).Unix()), + }), + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "ERROR: timestamp too old: got ts:", + }, + }, + // 13 - seconds check - less than 0 seconds + { + body: "{\"max_seconds_behind\": -1}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: types.NewBlockWithHeader(&types.Header{ + Time: uint64(time.Now().Add(1 * time.Hour).Unix()), + }), + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "OK", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "ERROR: invalid value provided", + }, + }, + // 14 - seconds check - badly formed request + { + body: "{\"max_seconds_behind\": \"ABC\"}", + clientPeerResult: uint64(1), + clientPeerError: nil, + clientBlockResult: &types.Block{}, + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusInternalServerError, + expectedBody: map[string]string{ + query: "ERROR: json: cannot unmarshal string into Go struct field requestBody.max_seconds_behind of type int", + synced: "DISABLED", + minPeerCount: "DISABLED", + checkBlock: "DISABLED", + maxSecondsBehind: "DISABLED", + }, + }, + // 15 - all checks - report ok + { + body: "{\"synced\": true, \"min_peer_count\": 1, \"check_block\": 10, \"max_seconds_behind\": 60}", + clientPeerResult: uint64(10), + clientPeerError: nil, + clientBlockResult: types.NewBlockWithHeader(&types.Header{ + Time: uint64(time.Now().Add(1 * time.Second).Unix()), + }), + clientBlockError: nil, + clientSyncingResult: nil, + clientSyncingError: nil, + expectedStatusCode: http.StatusOK, + expectedBody: map[string]string{ + query: "OK", + synced: "OK", + minPeerCount: "OK", + checkBlock: "OK", + maxSecondsBehind: "OK", + }, + }, + } + + for idx, c := range cases { + w := httptest.NewRecorder() + r, err := http.NewRequest(http.MethodGet, "http://localhost:9090/health", nil) + if err != nil { + t.Errorf("%v: creating request: %v", idx, err) + } + + r.Body = io.NopCloser(strings.NewReader(c.body)) + + ethClient := ðClientStub{ + peersResult: c.clientPeerResult, + peersError: c.clientPeerError, + blockResult: c.clientBlockResult, + blockError: c.clientBlockError, + syncingResult: c.clientSyncingResult, + syncingError: c.clientSyncingError, + } + + processFromBody(ethClient, w, r) + + result := w.Result() + if result.StatusCode != c.expectedStatusCode { + t.Errorf("%v: expected status code: %v, but got: %v", idx, c.expectedStatusCode, result.StatusCode) + } + + bodyBytes, err := io.ReadAll(result.Body) + if err != nil { + t.Errorf("%v: reading response body: %s", idx, err) + } + + var body map[string]string + err = json.Unmarshal(bodyBytes, &body) + if err != nil { + t.Errorf("%v: unmarshalling the response body: %s", idx, err) + } + result.Body.Close() + + for k, v := range c.expectedBody { + val, found := body[k] + if !found { + t.Errorf("%v: expected the key: %s to be in the response body but it wasn't there", idx, k) + } + if !strings.Contains(val, v) { + t.Errorf("%v: expected the response body key: %s to contain: %s, but it contained: %s", idx, k, v, val) + } + } + } +} diff --git a/health/interface.go b/health/interface.go new file mode 100644 index 0000000000..7ddbecd047 --- /dev/null +++ b/health/interface.go @@ -0,0 +1,15 @@ +package health + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/core/types" +) + +type ethClient interface { + PeerCount(context.Context) (uint64, error) + BlockByNumber(context.Context, *big.Int) (*types.Block, error) + SyncProgress(context.Context) (*ethereum.SyncProgress, error) +} diff --git a/health/service.go b/health/service.go new file mode 100644 index 0000000000..76232f15a2 --- /dev/null +++ b/health/service.go @@ -0,0 +1,40 @@ +package health + +import ( + "net/http" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/node" +) + +type handler struct { + ec *ethclient.Client +} + +// ServeHTTP implements the http.Handler interface. +func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + headers := r.Header.Values(healthHeader) + if len(headers) != 0 { + processFromHeaders(h.ec, headers, w, r) + } else { + processFromBody(h.ec, w, r) + } +} + +// New constructs a new health service instance. +func New(stack *node.Node, cors, vhosts []string) error { + _, err := newHandler(stack, cors, vhosts) + return err +} + +// newHandler returns a new `http.Handler` that will answer node health queries. +func newHandler(stack *node.Node, cors, vhosts []string) (*handler, error) { + ec := ethclient.NewClient(stack.Attach()) + h := handler{ec} + handler := node.NewHTTPHandlerStack(h, cors, vhosts, nil) + + stack.RegisterHandler("Health API", "/health", handler) + stack.RegisterHandler("Health API", "/health/", handler) + + return &h, nil +}