diff --git a/api/peeropts.go b/api/peeropts.go new file mode 100644 index 00000000..65eec95a --- /dev/null +++ b/api/peeropts.go @@ -0,0 +1,22 @@ +// Copyright © 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +// PeerOpts are the options for client side peer filtering. +type PeerOpts struct { + // State of the connection (disconnected, connecting, connected, disconnecting) + State []string + // Direction of the connection (inbound, outbound) + Direction []string +} diff --git a/api/v1/peers.go b/api/v1/peers.go new file mode 100644 index 00000000..c561ef88 --- /dev/null +++ b/api/v1/peers.go @@ -0,0 +1,83 @@ +package v1 + +import ( + "encoding/json" + "fmt" + + "github.com/pkg/errors" +) + +// Peer contains all the available information about a nodes peer. +type Peer struct { + PeerID string `json:"peer_id"` + Enr string `json:"enr,omitempty"` + LastSeenP2PAddress string `json:"last_seen_p2p_address"` + State string `json:"state"` + Direction string `json:"direction"` +} + +type peerJSON struct { + PeerID string `json:"peer_id"` + Enr string `json:"enr,omitempty"` + LastSeenP2PAddress string `json:"last_seen_p2p_address"` + State string `json:"state"` + Direction string `json:"direction"` +} + +// validPeerDirections are all the accepted options for peer direction. +var validPeerDirections = map[string]int{"inbound": 1, "outbound": 1} + +// validPeerStates are all the accepted options for peer states. +var validPeerStates = map[string]int{"connected": 1, "connecting": 1, "disconnected": 1, "disconnecting": 1} + +func (p *Peer) MarshalJSON() ([]byte, error) { + // make sure we have valid peer states and directions + _, exists := validPeerDirections[p.Direction] + if !exists { + return nil, fmt.Errorf("invalid value for peer direction: %s", p.Direction) + } + _, exists = validPeerStates[p.State] + if !exists { + return nil, fmt.Errorf("invalid value for peer state: %s", p.State) + } + + return json.Marshal(&peerJSON{ + PeerID: p.PeerID, + Enr: p.Enr, + LastSeenP2PAddress: p.LastSeenP2PAddress, + State: p.State, + Direction: p.Direction, + }) +} + +func (p *Peer) UnmarshalJSON(input []byte) error { + var peerJSON peerJSON + + if err := json.Unmarshal(input, &peerJSON); err != nil { + return errors.Wrap(err, "invalid JSON") + } + _, ok := validPeerStates[peerJSON.State] + if !ok { + return fmt.Errorf("invalid value for peer state: %s", peerJSON.State) + } + p.State = peerJSON.State + _, ok = validPeerDirections[peerJSON.Direction] + if !ok { + return fmt.Errorf("invalid value for peer direction: %s", peerJSON.Direction) + } + p.Direction = peerJSON.Direction + p.Enr = peerJSON.Enr + p.PeerID = peerJSON.PeerID + p.LastSeenP2PAddress = peerJSON.LastSeenP2PAddress + + return nil +} + +func (p *Peer) String() string { + data, err := json.Marshal(p) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/api/v1/peers_test.go b/api/v1/peers_test.go new file mode 100644 index 00000000..1ada6262 --- /dev/null +++ b/api/v1/peers_test.go @@ -0,0 +1,60 @@ +package v1 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + require "github.com/stretchr/testify/require" +) + +func TestNodePeerJSON(t *testing.T) { + tests := []struct { + name string + input []byte + err string + }{ + { + name: "Empty", + err: "unexpected end of JSON input", + }, + { + name: "GoodNoENR", + input: []byte(`{"peer_id":"16Uiu2HAm7ukVy4XugqVShYbLih4H2jBJjYevevznBZaHsmd1FM96","last_seen_p2p_address":"/ip4/10.0.20.8/tcp/43402","state":"connected","direction":"inbound"}`), + }, + { + name: "GoodWithENR", + input: []byte(`{"peer_id":"16Uiu2HAmTJgqKuVcN1QReyWzwELRkfWCjLAfBSu3KxuBuWFvvaLX","enr":"enr:-MS4QExfvXqHhj-nqAqkg1Sn55uV7YgpRtlImGCvMJkrkbnLDo8sGhecAGid9B3NjXzN3UtGxpOOUqHZVcEDQxkniwoBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpAEg2rFBAAGZgIAAAAAAAAAgmlkgnY0gmlwhAoAFBWEcXVpY4IjKYlzZWNwMjU2azGhA9mr4bIskWVeMt0dEn4IlQJhOFgOqgR9V3gkHTl1lTioiHN5bmNuZXRzAIN0Y3CCIyiDdWRwgiMo","last_seen_p2p_address":"/ip4/10.0.20.21/udp/9001/quic-v1/p2p/16Uiu2HAmTJgqKuVcN1QReyWzwELRkfWCjLAfBSu3KxuBuWFvvaLX","state":"connected","direction":"outbound"}`), + }, + { + name: "BadDirection", + input: []byte(`{"peer_id":"16Uiu2HAm7ukVy4XugqVShYbLih4H2jBJjYevevznBZaHsmd1FM96","last_seen_p2p_address":"/ip4/10.0.20.8/tcp/43402","state":"connected","direction":"backwards"}`), + err: "invalid value for peer direction: backwards", + }, + { + name: "BadState", + input: []byte(`{"peer_id":"16Uiu2HAm7ukVy4XugqVShYbLih4H2jBJjYevevznBZaHsmd1FM96","last_seen_p2p_address":"/ip4/10.0.20.8/tcp/43402","state":"tightly-coupled","direction":"inbound"}`), + err: "invalid value for peer state: tightly-coupled", + }, + { + name: "JSONBad", + input: []byte("[]"), + err: "invalid JSON: json: cannot unmarshal array into Go value of type v1.peerJSON", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var res Peer + err := json.Unmarshal(test.input, &res) + if test.err != "" { + require.EqualError(t, err, test.err) + } else { + require.NoError(t, err) + rt, err := json.Marshal(&res) + require.NoError(t, err) + assert.Equal(t, string(test.input), string(rt)) + assert.Equal(t, string(rt), res.String()) + } + }) + } +} diff --git a/http/nodepeers.go b/http/nodepeers.go new file mode 100644 index 00000000..626ef225 --- /dev/null +++ b/http/nodepeers.go @@ -0,0 +1,47 @@ +package http + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" +) + +func (s *Service) NodePeers(ctx context.Context, opts *api.PeerOpts) (*api.Response[[]*apiv1.Peer], error) { + // all options are considered optional + request := "/eth/v1/node/peers" + additionalFields := make([]string, 0, len(opts.State)+len(opts.Direction)) + + for _, stateFilter := range opts.State { + additionalFields = append(additionalFields, fmt.Sprintf("state=%s", stateFilter)) + } + + for _, directionFilter := range opts.Direction { + additionalFields = append(additionalFields, fmt.Sprintf("direction=%s", directionFilter)) + } + + if len(additionalFields) > 0 { + request = fmt.Sprintf("%s?%s", request, strings.Join(additionalFields, "&")) + } + + httpResponse, err := s.get2(ctx, request) + if err != nil { + return nil, err + } + + if httpResponse.contentType != ContentTypeJSON { + return nil, fmt.Errorf("unexpected content type %v (expected JSON)", httpResponse.contentType) + } + data, meta, err := decodeJSONResponse(bytes.NewReader(httpResponse.body), []*apiv1.Peer{}) + if err != nil { + return nil, err + } + + return &api.Response[[]*apiv1.Peer]{ + Data: data, + Metadata: meta, + }, nil +} diff --git a/http/nodepeers_test.go b/http/nodepeers_test.go new file mode 100644 index 00000000..b544a365 --- /dev/null +++ b/http/nodepeers_test.go @@ -0,0 +1,69 @@ +// Copyright © 2020, 2021 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package http_test + +import ( + "context" + client "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + "github.com/attestantio/go-eth2-client/http" + "github.com/stretchr/testify/require" + "os" + "testing" +) + +func TestNodePeers(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tests := []struct { + name string + opts *api.PeerOpts + }{ + { + name: "AllPeers", + opts: &api.PeerOpts{}, + }, + { + name: "AllInboundPeers", + opts: &api.PeerOpts{Direction: []string{"inbound"}}, + }, + { + name: "AllConnectedPeers", + opts: &api.PeerOpts{State: []string{"connected"}}, + }, + { + name: "AllConnectedOutboundPeers", + opts: &api.PeerOpts{ + State: []string{"connected"}, + Direction: []string{"outbound"}, + }, + }, + } + + service, err := http.New(ctx, + http.WithTimeout(timeout), + http.WithAddress(os.Getenv("HTTP_ADDRESS")), + ) + require.NoError(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, err := service.(client.NodePeersProvider).NodePeers(ctx, test.opts) + require.NoError(t, err) + require.NotNil(t, response) + require.NotNil(t, response.Data) + }) + } +} diff --git a/mock/nodepeers.go b/mock/nodepeers.go new file mode 100644 index 00000000..801dfb21 --- /dev/null +++ b/mock/nodepeers.go @@ -0,0 +1,33 @@ +// Copyright © 2021, 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mock + +import ( + "context" + + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" +) + +// NodePeers provides the peers of the node. +func (s *Service) NodePeers(_ context.Context, _ *api.PeerOpts) (*api.Response[[]*apiv1.Peer], error) { + return &api.Response[[]*apiv1.Peer]{ + Data: []*apiv1.Peer{{ + PeerID: "MOCK16Uiu2HAm7ukVy4XugqVShYbLih4H2jBJjYevevznBZaHsmd1FM96", + LastSeenP2PAddress: "/ip4/10.0.20.8/tcp/43402", + State: "connected", + Direction: "outbound", + }}, + }, nil +} diff --git a/multi/nodepeers.go b/multi/nodepeers.go new file mode 100644 index 00000000..182fd9d7 --- /dev/null +++ b/multi/nodepeers.go @@ -0,0 +1,39 @@ +// Copyright © 2021, 2023 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi + +import ( + "context" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" +) + +// NodePeers provides the peers of the node. +func (s *Service) NodePeers(ctx context.Context, opts *api.PeerOpts) (*api.Response[[]*apiv1.Peer], error) { + res, err := s.doCall(ctx, func(ctx context.Context, client consensusclient.Service) (interface{}, error) { + nodePeers, err := client.(consensusclient.NodePeersProvider).NodePeers(ctx, opts) + if err != nil { + return nil, err + } + + return nodePeers, nil + }, nil) + if err != nil { + return nil, err + } + + return res.(*api.Response[[]*apiv1.Peer]), nil +} diff --git a/multi/nodepeers_test.go b/multi/nodepeers_test.go new file mode 100644 index 00000000..c8a5120f --- /dev/null +++ b/multi/nodepeers_test.go @@ -0,0 +1,61 @@ +// Copyright © 2021 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package multi_test + +import ( + "context" + "testing" + + "github.com/attestantio/go-eth2-client/api" + + consensusclient "github.com/attestantio/go-eth2-client" + "github.com/attestantio/go-eth2-client/mock" + "github.com/attestantio/go-eth2-client/multi" + "github.com/attestantio/go-eth2-client/testclients" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func TestNodePeers(t *testing.T) { + ctx := context.Background() + + client1, err := mock.New(ctx, mock.WithName("mock 1")) + require.NoError(t, err) + erroringClient1, err := testclients.NewErroring(ctx, 0.1, client1) + require.NoError(t, err) + client2, err := mock.New(ctx, mock.WithName("mock 2")) + require.NoError(t, err) + erroringClient2, err := testclients.NewErroring(ctx, 0.1, client2) + require.NoError(t, err) + client3, err := mock.New(ctx, mock.WithName("mock 3")) + require.NoError(t, err) + + multiClient, err := multi.New(ctx, + multi.WithLogLevel(zerolog.Disabled), + multi.WithClients([]consensusclient.Service{ + erroringClient1, + erroringClient2, + client3, + }), + ) + require.NoError(t, err) + + for i := 0; i < 128; i++ { + res, err := multiClient.(consensusclient.NodePeersProvider).NodePeers(ctx, &api.PeerOpts{}) + require.NoError(t, err) + require.NotNil(t, res) + } + // At this point we expect mock 3 to be in active (unless probability hates us). + require.Equal(t, "mock 3", multiClient.Address()) +} diff --git a/service.go b/service.go index df87784e..a90d71f7 100644 --- a/service.go +++ b/service.go @@ -336,6 +336,12 @@ type NodeSyncingProvider interface { NodeSyncing(ctx context.Context) (*api.Response[*apiv1.SyncState], error) } +// NodePeersProvider is the interface for providing peer information. +type NodePeersProvider interface { + // NodePeers provides the peers of the node. + NodePeers(ctx context.Context, opts *api.PeerOpts) (*api.Response[[]*apiv1.Peer], error) +} + // ProposalPreparationsSubmitter is the interface for submitting proposal preparations. type ProposalPreparationsSubmitter interface { // SubmitProposalPreparations provides the beacon node with information required if a proposal for the given validators diff --git a/testclients/erroring.go b/testclients/erroring.go index 194ee67a..b038d774 100644 --- a/testclients/erroring.go +++ b/testclients/erroring.go @@ -560,6 +560,19 @@ func (s *Erroring) NodeSyncing(ctx context.Context) (*api.Response[*apiv1.SyncSt return next.NodeSyncing(ctx) } +// NodePeers provides the peers of the node. +func (s *Erroring) NodePeers(ctx context.Context, opts *api.PeerOpts) (*api.Response[[]*apiv1.Peer], error) { + if err := s.maybeError(ctx); err != nil { + return nil, err + } + next, isNext := s.next.(consensusclient.NodePeersProvider) + if !isNext { + return nil, fmt.Errorf("%s@%s does not support this call", s.next.Name(), s.next.Address()) + } + + return next.NodePeers(ctx, opts) +} + // ProposerDuties obtains proposer duties for the given epoch. func (s *Erroring) ProposerDuties(ctx context.Context, opts *api.ProposerDutiesOpts, diff --git a/testclients/sleepy.go b/testclients/sleepy.go index 2461328f..0b8dd973 100644 --- a/testclients/sleepy.go +++ b/testclients/sleepy.go @@ -426,6 +426,17 @@ func (s *Sleepy) NodeSyncing(ctx context.Context) (*api.Response[*apiv1.SyncStat return next.NodeSyncing(ctx) } +// NodePeers provides the peers of the node. +func (s *Sleepy) NodePeers(ctx context.Context, opts *api.PeerOpts) (*api.Response[[]*apiv1.Peer], error) { + s.sleep(ctx) + next, isNext := s.next.(consensusclient.NodePeersProvider) + if !isNext { + return nil, errors.New("next does not support this call") + } + + return next.NodePeers(ctx, opts) +} + // ProposerDuties obtains proposer duties for the given epoch. func (s *Sleepy) ProposerDuties(ctx context.Context, opts *api.ProposerDutiesOpts,