├── Makefile ├── collector_test.go ├── emit_test.go ├── LICENSE ├── emit.go ├── main.go ├── stats ├── report.go └── model.go ├── collector.go ├── server_test.go ├── README.md └── server.go /Makefile: -------------------------------------------------------------------------------- 1 | BINARY = $(notdir $(PWD)) 2 | VERSION := $(shell git describe --tags --dirty --always 2> /dev/null || echo "dev") 3 | SOURCES = $(wildcard *.go **/*.go) 4 | PKG := $(shell go list | head -n1) 5 | 6 | all: $(BINARY) 7 | 8 | $(BINARY): $(SOURCES) 9 | go build -ldflags "-X main.version=$(VERSION)" -o "$@" 10 | 11 | deps: 12 | go get ./... 13 | 14 | build: $(BINARY) 15 | 16 | clean: 17 | rm $(BINARY) 18 | 19 | run: $(BINARY) 20 | ./$(BINARY) 21 | 22 | debug: $(BINARY) 23 | ./$(BINARY) --pprof :6060 -vv 24 | 25 | test: 26 | go test -race ./... 27 | golint ./... 28 | -------------------------------------------------------------------------------- /collector_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/vipnode/ethstats/stats" 8 | ) 9 | 10 | func TestCollector(t *testing.T) { 11 | col := collector{} 12 | if err := col.Collect(stats.PingReport{"foo", time.Now()}); err != ErrNodeNotAuthorized { 13 | t.Errorf("collected unauthorized report: err=%q", err) 14 | } 15 | 16 | if err := col.Collect(stats.AuthReport{ID: "foo"}); err != nil { 17 | t.Errorf("failed to collect auth: err=%q", err) 18 | } 19 | 20 | if err := col.Collect(stats.PingReport{"foo", time.Now()}); err != nil { 21 | t.Errorf("failed to collect ping after auth: err=%q", err) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /emit_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestEmitMessage(t *testing.T) { 9 | tests := []struct { 10 | Emit EmitMessage 11 | Expected string 12 | }{ 13 | { 14 | EmitMessage{ 15 | Topic: "hello", 16 | Payload: json.RawMessage(`{"foo": 42}`), 17 | }, 18 | `{"emit":["hello",{"foo":42}]}`, 19 | }, 20 | { 21 | EmitMessage{ 22 | Topic: "ack", 23 | }, 24 | `{"emit":["ack"]}`, 25 | }, 26 | } 27 | 28 | for _, tc := range tests { 29 | out, err := tc.Emit.MarshalJSON() 30 | if err != nil { 31 | t.Errorf("failed to marshal: %q", err) 32 | } 33 | 34 | if got, want := string(out), tc.Expected; got != want { 35 | t.Errorf("got:\n\t%s; want\n\t%s", got, want) 36 | } 37 | } 38 | } 39 | 40 | func TestMarshalEmit(t *testing.T) { 41 | out, err := MarshalEmit("ready", nil) 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | 46 | if got, want := string(out), `{"emit":["ready"]}`; got != want { 47 | t.Errorf("got:\n\t%s; want\n\t%s", got, want) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 vipnode 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /emit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // EmitMessage contains a parsed SocksJS-style pubsub event emit. 9 | type EmitMessage struct { 10 | Topic string 11 | Payload json.RawMessage 12 | } 13 | 14 | func (emit *EmitMessage) UnmarshalJSON(data []byte) error { 15 | msg := struct { 16 | Emit []json.RawMessage `json:"emit"` 17 | }{} 18 | 19 | if err := json.Unmarshal(data, &msg); err != nil { 20 | return err 21 | } 22 | if len(msg.Emit) == 0 { 23 | return fmt.Errorf("missing emit fields") 24 | } 25 | if err := json.Unmarshal(msg.Emit[0], &emit.Topic); err != nil { 26 | return err 27 | } 28 | if len(msg.Emit) > 1 { 29 | emit.Payload = msg.Emit[1] 30 | } 31 | return nil 32 | } 33 | 34 | func (emit *EmitMessage) MarshalJSON() ([]byte, error) { 35 | msg := struct { 36 | Emit []json.RawMessage `json:"emit"` 37 | }{} 38 | 39 | if emit.Topic == "" { 40 | return nil, fmt.Errorf("missing topic") 41 | } 42 | rawTopic, err := json.Marshal(emit.Topic) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | msg.Emit = append(msg.Emit, rawTopic) 48 | if emit.Payload != nil { 49 | msg.Emit = append(msg.Emit, emit.Payload) 50 | } 51 | return json.Marshal(msg) 52 | } 53 | 54 | func MarshalEmit(topic string, payload interface{}) ([]byte, error) { 55 | emit := EmitMessage{ 56 | Topic: topic, 57 | } 58 | 59 | if payload != nil { 60 | rawPayload, err := json.Marshal(payload) 61 | if err != nil { 62 | return nil, err 63 | } 64 | emit.Payload = rawPayload 65 | } 66 | 67 | return emit.MarshalJSON() 68 | } 69 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/vipnode/ethstats/stats" 13 | "golang.org/x/crypto/acme/autocert" 14 | ) 15 | 16 | func main() { 17 | var ( 18 | addr = flag.String("listen", ":8080", "websocket address to listen on") 19 | id = flag.String("id", "vipstats", "id of the ethstats server") 20 | autotls = flag.Bool("autotls", true, "setup TLS on port :443 when listen is on port :80") 21 | ) 22 | 23 | ethstats := &Server{ 24 | Name: stats.ID(*id), 25 | } 26 | 27 | _, port, err := net.SplitHostPort(*addr) 28 | if err != nil { 29 | exit(1, "failed to parse address", err) 30 | } 31 | 32 | mux := http.NewServeMux() 33 | mux.HandleFunc("/api", ethstats.WebsocketHandler) 34 | mux.HandleFunc("/", ethstats.APIHandler) 35 | 36 | if port == "80" && *autotls { 37 | log.Print("starting autocert process") 38 | certManager := autocert.Manager{ 39 | Prompt: autocert.AcceptTOS, 40 | Cache: autocert.DirCache("certs"), 41 | } 42 | 43 | https := &http.Server{ 44 | Addr: ":443", 45 | Handler: mux, 46 | TLSConfig: &tls.Config{ 47 | GetCertificate: certManager.GetCertificate, 48 | }, 49 | } 50 | 51 | go http.ListenAndServe(":80", certManager.HTTPHandler(nil)) 52 | log.Fatal(https.ListenAndServeTLS("", "")) 53 | } else { 54 | log.Printf("listening on %s", *addr) 55 | log.Fatal(http.ListenAndServe(*addr, mux)) 56 | } 57 | 58 | } 59 | 60 | // exit prints an error and exits with the given code 61 | func exit(code int, msg string, a ...interface{}) { 62 | fmt.Fprintf(os.Stderr, msg+"\n", a...) 63 | os.Exit(code) 64 | } 65 | -------------------------------------------------------------------------------- /stats/report.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import "time" 4 | 5 | // Report is a container for some stats about a node. 6 | type Report interface { 7 | NodeID() ID 8 | } 9 | 10 | // AuthReport contains the authorization needed to log into a monitoring server. 11 | type AuthReport struct { 12 | ID ID `json:"id"` 13 | Info Info `json:"info"` 14 | Secret string `json:"secret"` 15 | } 16 | 17 | func (r AuthReport) NodeID() ID { 18 | return r.ID 19 | } 20 | 21 | // StatusReport contains the Status for a specific node ID 22 | type StatusReport struct { 23 | ID ID `json:"id"` 24 | Status Status `json:"stats"` 25 | } 26 | 27 | func (r StatusReport) NodeID() ID { 28 | return r.ID 29 | } 30 | 31 | // PendingReport contains the Pending stats for a specific node ID 32 | type PendingReport struct { 33 | ID ID `json:"id"` 34 | Pending Pending `json:"stats"` 35 | } 36 | 37 | func (r PendingReport) NodeID() ID { 38 | return r.ID 39 | } 40 | 41 | // BlockReport contains the Block stats for a specific node ID 42 | type BlockReport struct { 43 | ID ID `json:"id"` 44 | Block Block `json:"block"` 45 | } 46 | 47 | func (r BlockReport) NodeID() ID { 48 | return r.ID 49 | } 50 | 51 | // PingReport contains the client time for a specific node ID 52 | type PingReport struct { 53 | ID ID `json:"id"` 54 | ClientTime time.Time `json:"clientTime"` 55 | } 56 | 57 | func (r PingReport) NodeID() ID { 58 | return r.ID 59 | } 60 | 61 | // LatencyReport contains the latency to a specific node ID 62 | type LatencyReport struct { 63 | ID ID `json:"id"` 64 | Latency Latency `json:"latency"` 65 | } 66 | 67 | func (r LatencyReport) NodeID() ID { 68 | return r.ID 69 | } 70 | 71 | // DisconnectReport signals a disconnect event for a specific node ID. 72 | type DisconnectReport struct { 73 | ID ID `json:"id"` 74 | } 75 | 76 | func (r DisconnectReport) NodeID() ID { 77 | return r.ID 78 | } 79 | -------------------------------------------------------------------------------- /stats/model.go: -------------------------------------------------------------------------------- 1 | package stats // import "github.com/vipnode/ethstats/stats" 2 | 3 | import ( 4 | "encoding/json" 5 | "math/big" 6 | 7 | "github.com/ethereum/go-ethereum/common" 8 | "github.com/ethereum/go-ethereum/core/types" 9 | ) 10 | 11 | // These structs are partially borrowed from github.com/ethereu/go-ethereum/ethstats 12 | 13 | // ID is the identifire of the reporting node. 14 | type ID string 15 | 16 | // Info is the collection of metainformation about a node that is displayed 17 | // on the monitoring page. 18 | type Info struct { 19 | Name string `json:"name"` 20 | Node string `json:"node"` 21 | Port int `json:"port"` 22 | Network string `json:"net"` 23 | Protocol string `json:"protocol"` 24 | API string `json:"api"` 25 | Os string `json:"os"` 26 | OsVer string `json:"os_v"` 27 | Client string `json:"client"` 28 | History bool `json:"canUpdateHistory"` 29 | } 30 | 31 | // Block is the information to report about individual blocks. 32 | type Block struct { 33 | Number *big.Int `json:"number"` 34 | Hash common.Hash `json:"hash"` 35 | ParentHash common.Hash `json:"parentHash"` 36 | Timestamp *big.Int `json:"timestamp"` 37 | Miner common.Address `json:"miner"` 38 | GasUsed uint64 `json:"gasUsed"` 39 | GasLimit uint64 `json:"gasLimit"` 40 | Diff string `json:"difficulty"` 41 | TotalDiff string `json:"totalDifficulty"` 42 | Txs []TxStats `json:"transactions"` 43 | TxHash common.Hash `json:"transactionsRoot"` 44 | Root common.Hash `json:"stateRoot"` 45 | Uncles uncleStats `json:"uncles"` 46 | } 47 | 48 | // uncleStats is a custom wrapper around an uncle array to force serializing 49 | // empty arrays instead of returning null for them. 50 | type uncleStats []*types.Header 51 | 52 | func (s uncleStats) MarshalJSON() ([]byte, error) { 53 | if uncles := ([]*types.Header)(s); len(uncles) > 0 { 54 | return json.Marshal(uncles) 55 | } 56 | return []byte("[]"), nil 57 | } 58 | 59 | // TxStats is the information to report about individual transactions. 60 | type TxStats struct { 61 | Hash common.Hash `json:"hash"` 62 | } 63 | 64 | // Pending is the information to report about pending transactions. 65 | type Pending struct { 66 | Pending int `json:"pending"` 67 | } 68 | 69 | // Status is the information to report about the local node. 70 | type Status struct { 71 | Active bool `json:"active"` 72 | Syncing bool `json:"syncing"` 73 | Mining bool `json:"mining"` 74 | Hashrate int `json:"hashrate"` 75 | Peers int `json:"peers"` 76 | GasPrice int `json:"gasPrice"` 77 | Uptime int `json:"uptime"` 78 | } 79 | 80 | // Latency is the ping from the ethstats server to the node 81 | type Latency string 82 | -------------------------------------------------------------------------------- /collector.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "sync" 7 | "time" 8 | 9 | "github.com/vipnode/ethstats/stats" 10 | ) 11 | 12 | // ErrNodeNotAuthorized is returned when a report is received for a node that has 13 | // not been authorized yet. 14 | var ErrNodeNotAuthorized = errors.New("node has not been authorized") 15 | 16 | // ErrAuthFailed is returned when a node fails to authorize. 17 | var ErrAuthFailed = errors.New("authorization failed") 18 | 19 | // ErrInvalidReport is returned when the collector receives an invalid type. 20 | var ErrInvalidReport = errors.New("invalid report") 21 | 22 | // Node contains all the stats metadata about an Ethereum node. 23 | type Node struct { 24 | ID stats.ID `json:"id"` 25 | Info stats.Info `json:"info"` 26 | Latency stats.Latency `json:"latency"` 27 | Block stats.Block `json:"block"` 28 | Pending stats.Pending `json:"pending"` 29 | Status stats.Status `json:"status"` 30 | LastSeen time.Time `json:"last_seen"` 31 | } 32 | 33 | type collector struct { 34 | mu sync.RWMutex 35 | nodes map[stats.ID]Node 36 | auth func(stats.AuthReport) bool 37 | } 38 | 39 | func (col *collector) Collect(report stats.Report) error { 40 | col.mu.Lock() 41 | defer col.mu.Unlock() 42 | 43 | // TODO: Uncollect on disconnect? Or sweep based on last seen? 44 | if col.nodes == nil { 45 | (*col).nodes = map[stats.ID]Node{} 46 | } 47 | 48 | if authReport, ok := report.(stats.AuthReport); ok { 49 | if col.auth != nil && !col.auth(authReport) { 50 | return ErrAuthFailed 51 | } 52 | col.nodes[authReport.ID] = Node{ 53 | ID: authReport.ID, 54 | Info: authReport.Info, 55 | LastSeen: time.Now(), 56 | } 57 | log.Printf("collected node: %s", authReport.ID) 58 | return nil 59 | } 60 | 61 | node, ok := col.nodes[report.NodeID()] 62 | if !ok { 63 | return ErrNodeNotAuthorized 64 | } 65 | node.LastSeen = time.Now() 66 | 67 | switch report := report.(type) { 68 | case stats.LatencyReport: 69 | node.Latency = report.Latency 70 | case stats.BlockReport: 71 | node.Block = report.Block 72 | case stats.PendingReport: 73 | node.Pending = report.Pending 74 | case stats.StatusReport: 75 | node.Status = report.Status 76 | case stats.DisconnectReport: 77 | delete(col.nodes, report.NodeID()) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // Get returns a Node with the given ID, if it has been collected. 84 | func (col *collector) Get(ID stats.ID) (Node, bool) { 85 | col.mu.RLock() 86 | defer col.mu.RUnlock() 87 | 88 | node, ok := col.nodes[ID] 89 | return node, ok 90 | } 91 | 92 | // List returns a slice of IDs that are being collected. 93 | func (col *collector) List() []stats.ID { 94 | col.mu.RLock() 95 | defer col.mu.RUnlock() 96 | 97 | ids := make([]stats.ID, 0, len(col.nodes)) 98 | for id := range col.nodes { 99 | ids = append(ids, id) 100 | } 101 | return ids 102 | } 103 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/vipnode/ethstats/stats" 8 | ) 9 | 10 | const authMsg = `{ 11 | "emit": [ 12 | "hello", 13 | { 14 | "id": "foo", 15 | "info": { 16 | "name": "foo", 17 | "node": "Geth/v1.8.3-unstable/linux-amd64/go1.10", 18 | "port": 30303, 19 | "net": "1", 20 | "protocol": "les/2", 21 | "api": "No", 22 | "os": "linux", 23 | "os_v": "amd64", 24 | "client": "0.1.1", 25 | "canUpdateHistory": true 26 | }, 27 | "secret": "" 28 | } 29 | ] 30 | }` 31 | 32 | const pingMsg = `{ 33 | "emit": [ 34 | "node-ping", 35 | { 36 | "clientTime": "2018-05-17T21:15:15.389Z", 37 | "id": "foo" 38 | } 39 | ] 40 | }` 41 | 42 | // NOTE: For some reason geth uses time.Time.string() which includes a monotonic offset 43 | // that does not unmarshal properly. 44 | // Eg: {"clientTime": "2018-05-17 16:53:43.96985387 -0400 EDT m=+15.136170456"} 45 | 46 | func TestParseAuth(t *testing.T) { 47 | var emit EmitMessage 48 | if err := emit.UnmarshalJSON([]byte(authMsg)); err != nil { 49 | t.Fatalf("failed to parse: %q", err) 50 | } 51 | 52 | if emit.Topic != "hello" { 53 | t.Errorf("unexpected emit topic: %q", emit.Topic) 54 | } 55 | 56 | report := stats.AuthReport{} 57 | if err := json.Unmarshal(emit.Payload, &report); err != nil { 58 | t.Errorf("failed to parse: %q", err) 59 | } 60 | if report.ID != "foo" { 61 | t.Errorf("incorrect ID: %q", report.ID) 62 | } 63 | } 64 | 65 | func TestParsePing(t *testing.T) { 66 | var emitMsg EmitMessage 67 | if err := emitMsg.UnmarshalJSON([]byte(pingMsg)); err != nil { 68 | t.Fatalf("failed to parse: %q", err) 69 | } 70 | 71 | if emitMsg.Topic != "node-ping" { 72 | t.Errorf("unexpected emit topic: %q", emitMsg.Topic) 73 | } 74 | 75 | var r stats.PingReport 76 | if err := json.Unmarshal(emitMsg.Payload, &r); err != nil { 77 | t.Fatalf("failed to parse: %q", err) 78 | } 79 | 80 | if r.ID != "foo" { 81 | t.Errorf("incorrect ID: %q", r.ID) 82 | } 83 | 84 | if r.ClientTime.Second() != 15 { 85 | t.Errorf("incorrect timestamp: %q", r.ClientTime) 86 | } 87 | } 88 | 89 | const blockMsg = `{"emit":["block",{"block":{"number":5273251,"hash":"0xe11bc629a85375753ba5a043e5b44c05dedbdb484ed8956f9aec07bf3d93fde5","parentHash":"0x10aa19d73522d15cf004ca602b3b87d79bb903d5f7ba8745fc7959534047c7de","timestamp":1521317517,"miner":"0xb2930b35844a230f00e51431acae96fe543a0347","gasUsed":7984834,"gasLimit":7999992,"difficulty":"3291915733734816","totalDifficulty":"3102951517281028058241","transactions":[],"transactionsRoot":"0xe2fdfcc5707a06727f7624ae01c8a7128194b4fc88579375f2ab96e3bdc12d08","stateRoot":"0xdb34c6952061b45c9f4875ed70475cddd3cee0ba016afbd4c2418bbe9ca539d4","uncles":[]},"id":"a"}]}` 90 | 91 | func TestParseBlock(t *testing.T) { 92 | var emitMsg EmitMessage 93 | if err := emitMsg.UnmarshalJSON([]byte(blockMsg)); err != nil { 94 | t.Fatalf("failed to parse: %q", err) 95 | } 96 | 97 | if emitMsg.Topic != "block" { 98 | t.Errorf("unexpected emit topic: %q", emitMsg.Topic) 99 | } 100 | 101 | node := Node{} 102 | container := struct { 103 | Block *stats.Block `json:"block"` 104 | ID string `json:"id"` 105 | }{ 106 | Block: &node.Block, 107 | } 108 | if err := json.Unmarshal(emitMsg.Payload, &container); err != nil { 109 | t.Fatalf("failed to parse: %q", err) 110 | } 111 | 112 | if node.Block.Number.String() != "5273251" { 113 | t.Errorf("incorrect block number: %q", node.Block.Number) 114 | } 115 | 116 | if node.Block.Hash.String() != "0xe11bc629a85375753ba5a043e5b44c05dedbdb484ed8956f9aec07bf3d93fde5" { 117 | t.Errorf("incorrect block hash: %q", node.Block.Hash) 118 | } 119 | } 120 | 121 | const statsMsg = `{"emit":["stats",{"id":"a","stats":{"active":true,"syncing":true,"mining":false,"hashrate":0,"peers":0,"gasPrice":0,"uptime":100}}]}` 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ethstats 2 | 3 | Go implementation of an ethstats collection server. 4 | 5 | ## Endpoints 6 | 7 | - `/api` is a WebSocket endpoint for collecting ethstats. 8 | - `/` returns a JSON response with the current connected nodes. Details can be 9 | fetched by specifying the node ID with the `node` param. 10 | 11 | ## Quickstart 12 | 13 | ``` 14 | $ make run 15 | 2018/05/24 16:34:18 listening on :8080 16 | ``` 17 | 18 | ``` 19 | $ geth --ethstats "somenodeid:somesecret@localhost:8080" 20 | ``` 21 | 22 | ``` 23 | $ curl "http://localhost:8080/" 24 | {"nodes":["somenodeid"]} 25 | $ curl "http://localhost:8080/?node=somenodeid" 26 | {"id":"somenodeid","info":{"name":"somenodeid","node":"Geth/testmooch/v1.8.3-unstable/linux-amd64/go1.10","port":30303,"net":"1","protocol":"les/2","api":"No","os":"linux","os_v":"amd64","client":"0.1.1","canUpdateHistory":true},"latency":"","block":{"number":null,"hash":"0x0000000000000000000000000000000000000000000000000000000000000000","parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","timestamp":null,"miner":"0x0000000000000000000000000000000000000000","gasUsed":0,"gasLimit":0,"difficulty":"","totalDifficulty":"","transactions":null,"transactionsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","uncles":[]},"pending":{"pending":0},"status":{"active":false,"syncing":false,"mining":false,"hashrate":0,"peers":0,"gasPrice":0,"uptime":0},"last_seen":"2018-05-24T16:35:35.116948798-04:00"} 27 | ``` 28 | 29 | ## Appendix 30 | 31 | ### Challenges with the netstats/ethstats protocol 32 | 33 | - Undocumented. Must be reverse engineered from existing implementations (only 2 at present, linked in appendix). 34 | - No reusable libraries/schemas/SDKs. 35 | - Inconsistent implementations. 36 | (Example: Node version sends `clientTime` timestamps in a different format than the Geth built-in implementation.) 37 | - Unused components of protocol. 38 | (Example: Node and Geth implementations ignore the `clientTime` field from the response, which is probably for the best since they're incompatible.) 39 | - Inconsistent containers. Some responses have an extra redundant object container, others do not. 40 | (Example: `hello`, `node-ping`, `latency` are contained immediately in the payload, while `block` is contained under another `{"block": {payload}}` layer, and both `pending` and `stats` are contained under `{"stats": {payload}}`) 41 | - Lacking node authentication (only has authorization). Would be nice if the auth handshake included a signed message from the enodeID. 42 | - Non-standard framework-specific websocket payload format (`{"emit": ["topic", {payload}}`). Non-homogeneous array types are unnecessarily frustrating to work with. 43 | - No ability for the server to throttle the rate from the clients. 44 | - Lacking metrics about peers (only peer count). 45 | - Lacking metrics about the server/runtime. 46 | 47 | 48 | ### Ethstats v2 Protocol Wishlist 49 | 50 | - Clearly defined request/response schemas which are easy to use across programming languages. Perhaps GRPC or plain Protobufs? The goal should be to get native support from every major Ethereum client. 51 | - Connections identified and authenticated by EnodeID (challenge signed by Enode private key?). 52 | - Support for sharing the full peer list. This is useful for validating bi-direcitonal peer serving claims (such as for vipnode). 53 | - Support for sharing node runtime metrics. This is useful for maintainers of large fleets of nodes, and debugging platform-specific performance quirks. 54 | - Designed to be easily integrated with mainstream timeseries tooling like Prometheus/InfluxDB. 55 | - Support for relaying signed ethstats reports. 56 | 57 | 58 | ### References 59 | 60 | - https://github.com/cubedro/eth-net-intelligence-api/blob/bdc192ebb76fc9964ef0da83ee88bc86ba69c052/lib/node.js 61 | - https://github.com/ethereum/go-ethereum/blob/6286c255f16a914b39ffd3389cba154a53e66a13/ethstats/ethstats.go 62 | 63 | ## License 64 | 65 | MIT. 66 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/gobwas/ws" 11 | "github.com/gobwas/ws/wsutil" 12 | "github.com/vipnode/ethstats/stats" 13 | ) 14 | 15 | func renderJSON(w http.ResponseWriter, body interface{}) { 16 | w.WriteHeader(http.StatusOK) 17 | w.Header().Set("Content-Type", "application/json") 18 | if err := json.NewEncoder(w).Encode(body); err != nil { 19 | http.Error(w, err.Error(), 500) 20 | return 21 | } 22 | } 23 | 24 | type Server struct { 25 | collector 26 | Name stats.ID 27 | } 28 | 29 | func (srv *Server) APIHandler(w http.ResponseWriter, r *http.Request) { 30 | nodeID := r.FormValue("node") 31 | 32 | if nodeID == "" { 33 | response := struct { 34 | Nodes []stats.ID `json:"nodes"` 35 | }{ 36 | Nodes: srv.List(), 37 | } 38 | renderJSON(w, response) 39 | return 40 | } 41 | 42 | node, ok := srv.Get(stats.ID(nodeID)) 43 | if !ok { 44 | http.NotFound(w, r) 45 | return 46 | } 47 | 48 | renderJSON(w, node) 49 | } 50 | 51 | func (srv *Server) WebsocketHandler(w http.ResponseWriter, r *http.Request) { 52 | log.Print("connected, upgrading", r) 53 | conn, _, _, err := ws.UpgradeHTTP(r, w) 54 | if err != nil { 55 | http.Error(w, err.Error(), 500) 56 | return 57 | } 58 | 59 | go func() { 60 | err := srv.Join(conn) 61 | // TODO: Detect normal disconnects and ignore them? 62 | if err != nil { 63 | log.Printf("closing connection after error: %s", err) 64 | } 65 | }() 66 | } 67 | 68 | // Join runs the event loop for a connection, should be run in a goroutine. 69 | func (srv *Server) Join(conn net.Conn) error { 70 | defer conn.Close() 71 | var err error 72 | var emit EmitMessage 73 | 74 | r := wsutil.NewReader(conn, ws.StateServerSide) 75 | w := wsutil.NewWriter(conn, ws.StateServerSide, ws.OpText) 76 | 77 | decoder := json.NewDecoder(r) 78 | encoder := json.NewEncoder(w) 79 | 80 | for { 81 | // Prepare for the next message 82 | if _, err = r.NextFrame(); err != nil { 83 | return err 84 | } 85 | // Decode next message 86 | if err = decoder.Decode(&emit); err != nil { 87 | return err 88 | } 89 | 90 | log.Printf("%s: received topic: %s", conn.RemoteAddr(), emit.Topic) 91 | 92 | // TODO: Support relaying by trusting and mapping ID? 93 | switch topic := emit.Topic; topic { 94 | case "hello": 95 | report := stats.AuthReport{} 96 | if err = json.Unmarshal(emit.Payload, &report); err != nil { 97 | break 98 | } 99 | if err = srv.Collect(report); err != nil { 100 | break 101 | } 102 | defer func() { 103 | srv.Collect(stats.DisconnectReport{report.NodeID()}) 104 | }() 105 | err = encoder.Encode(&EmitMessage{ 106 | Topic: "ready", 107 | }) 108 | case "node-ping": 109 | // Every ethstats implementation ignores the clientTime in 110 | // the response here, and there is no standard format (eg. 111 | // geth sends a monotonic offset) so we'll ignore it too. 112 | sendPayload, err := json.Marshal(&stats.PingReport{srv.Name, time.Now()}) 113 | if err != nil { 114 | break 115 | } 116 | // TODO: We could reuse a sendPayload buffer above 117 | err = encoder.Encode(&EmitMessage{ 118 | Topic: "node-pong", 119 | Payload: sendPayload, 120 | }) 121 | case "latency": 122 | report := stats.LatencyReport{} 123 | if err = json.Unmarshal(emit.Payload, &report); err != nil { 124 | break 125 | } 126 | err = srv.Collect(report) 127 | case "block": 128 | report := stats.BlockReport{} 129 | if err = json.Unmarshal(emit.Payload, &report); err != nil { 130 | break 131 | } 132 | err = srv.Collect(report) 133 | case "pending": 134 | report := stats.PendingReport{} 135 | if err = json.Unmarshal(emit.Payload, &report); err != nil { 136 | break 137 | } 138 | err = srv.Collect(report) 139 | case "stats": 140 | report := stats.StatusReport{} 141 | if err = json.Unmarshal(emit.Payload, &report); err != nil { 142 | break 143 | } 144 | err = srv.Collect(report) 145 | default: 146 | continue 147 | } 148 | 149 | if err != nil { 150 | return err 151 | } 152 | 153 | // Write buffer 154 | if err = w.Flush(); err != nil { 155 | return err 156 | } 157 | } 158 | } 159 | --------------------------------------------------------------------------------