diff --git a/app/pocket/doc/CHANGELOG.md b/app/pocket/doc/CHANGELOG.md index d3b0055d0..6c80f4062 100644 --- a/app/pocket/doc/CHANGELOG.md +++ b/app/pocket/doc/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.0.6] - 2023-02-20 + +- Added the ability to override the default `BootstrapNodes` via flag + ## [0.0.0.5] - 2023-02-17 - Removed unnecessary server mode enabling call via `EnableServerMode()` function diff --git a/app/pocket/main.go b/app/pocket/main.go index 907b119a3..99288abc2 100644 --- a/app/pocket/main.go +++ b/app/pocket/main.go @@ -12,6 +12,7 @@ import ( func main() { configFilename := flag.String("config", "", "Relative or absolute path to the config file.") genesisFilename := flag.String("genesis", "", "Relative or absolute path to the genesis file.") + bootstrapNodes := flag.String("bootstrap-nodes", "", "Comma separated list of bootstrap nodes.") v := flag.Bool("version", false, "") flag.Parse() @@ -21,7 +22,12 @@ func main() { return } - runtimeMgr := runtime.NewManagerFromFiles(*configFilename, *genesisFilename) + options := []func(*runtime.Manager){} + if bootstrapNodes != nil && *bootstrapNodes != "" { + options = append(options, runtime.WithBootstrapNodes(*bootstrapNodes)) + } + + runtimeMgr := runtime.NewManagerFromFiles(*configFilename, *genesisFilename, options...) bus, err := runtime.CreateBus(runtimeMgr) if err != nil { logger.Global.Fatal().Err(err).Msg("Failed to create bus") diff --git a/p2p/CHANGELOG.md b/p2p/CHANGELOG.md index 2f82db9e9..f9c7e64be 100644 --- a/p2p/CHANGELOG.md +++ b/p2p/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.0.28] - 2023-02-20 + +- Added basic `bootstrap` nodes support +- Reacting to `ConsensusNewHeightEventType` and `StateMachineTransitionEventType` to update the address book and current height and determine if a bootstrap is needed + ## [0.0.0.27] - 2023-02-17 - Deprecated `debugAddressBookProvider` diff --git a/p2p/bootstrap.go b/p2p/bootstrap.go new file mode 100644 index 000000000..ad4dee9aa --- /dev/null +++ b/p2p/bootstrap.go @@ -0,0 +1,105 @@ +package p2p + +import ( + "context" + "encoding/csv" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + + rpcABP "github.com/pokt-network/pocket/p2p/providers/addrbook_provider/rpc" + rpcCHP "github.com/pokt-network/pocket/p2p/providers/current_height_provider/rpc" + typesP2P "github.com/pokt-network/pocket/p2p/types" + "github.com/pokt-network/pocket/rpc" + "github.com/pokt-network/pocket/runtime/defaults" +) + +// configureBootstrapNodes parses the bootstrap nodes from the config and validates them +func (m *p2pModule) configureBootstrapNodes() error { + p2pCfg := m.GetBus().GetRuntimeMgr().GetConfig().P2P + + bootstrapNodesCsv := strings.Trim(p2pCfg.BootstrapNodesCsv, " ") + if bootstrapNodesCsv == "" { + bootstrapNodesCsv = defaults.DefaultP2PBootstrapNodesCsv + } + csvReader := csv.NewReader(strings.NewReader(bootstrapNodesCsv)) + bootStrapNodes, err := csvReader.Read() + if err != nil { + return fmt.Errorf("error parsing bootstrap nodes: %w", err) + } + + // validate the bootstrap nodes + for i, node := range bootStrapNodes { + bootStrapNodes[i] = strings.Trim(node, " ") + if !isValidHostnamePort(bootStrapNodes[i]) { + return fmt.Errorf("invalid bootstrap node: %s", bootStrapNodes[i]) + } + } + m.bootstrapNodes = bootStrapNodes + return nil +} + +// bootstrap attempts to bootstrap from a bootstrap node +func (m *p2pModule) bootstrap() error { + var addrBook typesP2P.AddrBook + + for _, bootstrapNode := range m.bootstrapNodes { + m.logger.Info().Str("endpoint", bootstrapNode).Msg("Attempting to bootstrap from bootstrap node") + + client, err := rpc.NewClientWithResponses(bootstrapNode) + if err != nil { + continue + } + healthCheck, err := client.GetV1Health(context.TODO()) + if err != nil || healthCheck == nil || healthCheck.StatusCode != http.StatusOK { + m.logger.Warn().Str("bootstrapNode", bootstrapNode).Msg("Error getting a green health check from bootstrap node") + continue + } + + addressBookProvider := rpcABP.NewRPCAddrBookProvider( + rpcABP.WithP2PConfig( + m.GetBus().GetRuntimeMgr().GetConfig().P2P, + ), + rpcABP.WithCustomRPCUrl(bootstrapNode), + ) + + currentHeightProvider := rpcCHP.NewRPCCurrentHeightProvider(rpcCHP.WithCustomRPCUrl(bootstrapNode)) + + addrBook, err = addressBookProvider.GetStakedAddrBookAtHeight(currentHeightProvider.CurrentHeight()) + if err != nil { + m.logger.Warn().Err(err).Str("endpoint", bootstrapNode).Msg("Error getting address book from bootstrap node") + continue + } + } + + if len(addrBook) == 0 { + return fmt.Errorf("bootstrap failed") + } + + for _, peer := range addrBook { + m.logger.Debug().Str("address", peer.Address.String()).Msg("Adding peer to addrBook") + if err := m.network.AddPeerToAddrBook(peer); err != nil { + return err + } + } + return nil +} + +func isValidHostnamePort(str string) bool { + pattern := regexp.MustCompile(`^(https?)://([a-zA-Z0-9.-]+):(\d{1,5})$`) + matches := pattern.FindStringSubmatch(str) + if len(matches) != 4 { + return false + } + protocol := matches[1] + if protocol != "http" && protocol != "https" { + return false + } + port, err := strconv.Atoi(matches[3]) + if err != nil || port < 0 || port > 65535 { + return false + } + return true +} diff --git a/p2p/event_handler.go b/p2p/event_handler.go index 7041235f4..266a5ac96 100644 --- a/p2p/event_handler.go +++ b/p2p/event_handler.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/pokt-network/pocket/shared/codec" + coreTypes "github.com/pokt-network/pocket/shared/core/types" "github.com/pokt-network/pocket/shared/messaging" "google.golang.org/protobuf/types/known/anypb" ) @@ -40,6 +41,27 @@ func (m *p2pModule) HandleEvent(event *anypb.Any) error { } } + case messaging.StateMachineTransitionEventType: + stateMachineTransitionEvent, ok := evt.(*messaging.StateMachineTransitionEvent) + if !ok { + return fmt.Errorf("failed to cast event to StateMachineTransitionEvent") + } + + if stateMachineTransitionEvent.NewState == string(coreTypes.StateMachineState_P2P_Bootstrapping) { + addrBook := m.network.GetAddrBook() + if len(addrBook) == 0 { + m.logger.Warn().Msg("No peers in addrbook, bootstrapping") + + if err := m.bootstrap(); err != nil { + return err + } + } + m.logger.Info().Bool("TODO", true).Msg("Advertise self to network") + if err := m.GetBus().GetStateMachineModule().SendEvent(coreTypes.StateMachineEvent_P2P_IsBootstrapped); err != nil { + return err + } + } + default: return fmt.Errorf("unknown event type: %s", event.MessageName()) } diff --git a/p2p/module.go b/p2p/module.go index 5968cc9e1..533fc7397 100644 --- a/p2p/module.go +++ b/p2p/module.go @@ -35,6 +35,8 @@ type p2pModule struct { addrBookProvider providers.AddrBookProvider currentHeightProvider providers.CurrentHeightProvider + + bootstrapNodes []string } func Create(bus modules.Bus, options ...modules.ModuleOption) (modules.Module, error) { @@ -55,6 +57,10 @@ func (*p2pModule) Create(bus modules.Bus, options ...modules.ModuleOption) (modu cfg := runtimeMgr.GetConfig() p2pCfg := cfg.P2P + if err := m.configureBootstrapNodes(); err != nil { + return nil, err + } + privateKey, err := cryptoPocket.NewPrivateKey(p2pCfg.PrivateKey) if err != nil { return nil, err diff --git a/p2p/module_test.go b/p2p/module_test.go new file mode 100644 index 000000000..cd8af1258 --- /dev/null +++ b/p2p/module_test.go @@ -0,0 +1,129 @@ +package p2p + +import ( + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/pokt-network/pocket/runtime/configs" + "github.com/pokt-network/pocket/runtime/defaults" + cryptoPocket "github.com/pokt-network/pocket/shared/crypto" + "github.com/pokt-network/pocket/shared/modules" + mockModules "github.com/pokt-network/pocket/shared/modules/mocks" + "github.com/stretchr/testify/require" +) + +func Test_Create_configureBootstrapNodes(t *testing.T) { + defaultBootstrapNodes := strings.Split(defaults.DefaultP2PBootstrapNodesCsv, ",") + key := cryptoPocket.GetPrivKeySeed(1) + + type args struct { + initialBootstrapNodesCsv string + } + tests := []struct { + name string + args args + wantBootstrapNodes []string + wantErr bool + }{ + { + name: "unset boostrap nodes should yield no error and return DefaultP2PBootstrapNodes", + args: args{}, + wantErr: false, + wantBootstrapNodes: defaultBootstrapNodes, + }, + { + name: "empty string boostrap nodes should yield no error and return DefaultP2PBootstrapNodes", + args: args{ + initialBootstrapNodesCsv: "", + }, + wantErr: false, + wantBootstrapNodes: defaultBootstrapNodes, + }, + { + name: "untrimmed empty string boostrap nodes should yield no error and return DefaultP2PBootstrapNodes", + args: args{ + initialBootstrapNodesCsv: " ", + }, + wantErr: false, + wantBootstrapNodes: defaultBootstrapNodes, + }, + { + name: "untrimmed string boostrap nodes should yield no error and return the trimmed urls", + args: args{ + initialBootstrapNodesCsv: " http://somenode:50832 , http://someothernode:50832 ", + }, + wantErr: false, + wantBootstrapNodes: []string{"http://somenode:50832", "http://someothernode:50832"}, + }, + { + name: "custom bootstrap nodes should yield no error and return the custom bootstrap node", + args: args{ + initialBootstrapNodesCsv: "http://somenode:50832,http://someothernode:50832", + }, + wantBootstrapNodes: []string{"http://somenode:50832", "http://someothernode:50832"}, + wantErr: false, + }, + { + name: "malformed bootstrap nodes string should yield an error and return nil", + args: args{ + initialBootstrapNodesCsv: "\n\n", + }, + wantBootstrapNodes: []string(nil), + wantErr: true, + }, + { + name: "port number too high yields an error and empty list of bootstrap nodes", + args: args{ + initialBootstrapNodesCsv: "http://somenode:99999", + }, + wantBootstrapNodes: []string(nil), + wantErr: true, + }, + { + name: "negative port number yields an error and empty list of bootstrap nodes", + args: args{ + initialBootstrapNodesCsv: "udp://somenode:-8080", + }, + wantBootstrapNodes: []string(nil), + wantErr: true, + }, + { + name: "wrong protocol yields an error and empty list of bootstrap nodes", + args: args{ + initialBootstrapNodesCsv: "udp://somenode:58884", + }, + wantBootstrapNodes: []string(nil), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mockRuntimeMgr := mockModules.NewMockRuntimeMgr(ctrl) + mockBus := createMockBus(t, mockRuntimeMgr) + mockConsensusModule := mockModules.NewMockConsensusModule(ctrl) + mockBus.EXPECT().GetConsensusModule().Return(mockConsensusModule).AnyTimes() + mockRuntimeMgr.EXPECT().GetConfig().Return(&configs.Config{ + PrivateKey: key.String(), + P2P: &configs.P2PConfig{ + BootstrapNodesCsv: tt.args.initialBootstrapNodesCsv, + PrivateKey: key.String(), + }, + }).AnyTimes() + mockBus.EXPECT().GetRuntimeMgr().Return(mockRuntimeMgr).AnyTimes() + + var p2pMod modules.Module + p2pMod, err := Create(mockBus) + if (err != nil) != tt.wantErr { + t.Errorf("p2pModule.Create() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + actualBootstrapNodes := p2pMod.(*p2pModule).bootstrapNodes + require.EqualValues(t, tt.wantBootstrapNodes, actualBootstrapNodes) + } + }) + } +} diff --git a/p2p/providers/current_height_provider/rpc/provider.go b/p2p/providers/current_height_provider/rpc/provider.go index 972b399fb..3624d8361 100644 --- a/p2p/providers/current_height_provider/rpc/provider.go +++ b/p2p/providers/current_height_provider/rpc/provider.go @@ -65,7 +65,7 @@ func (rchp *rpcCurrentHeightProvider) CurrentHeight() uint64 { func NewRPCCurrentHeightProvider(options ...modules.ModuleOption) *rpcCurrentHeightProvider { rchp := &rpcCurrentHeightProvider{ - rpcUrl: fmt.Sprintf("http://%s:%s", rpcHost, defaults.DefaultRPCPort), + rpcUrl: fmt.Sprintf("http://%s:%s", rpcHost, defaults.DefaultRPCPort), // TODO: Make port configurable } for _, o := range options { diff --git a/p2p/utils_test.go b/p2p/utils_test.go index b6cdbf560..205142842 100644 --- a/p2p/utils_test.go +++ b/p2p/utils_test.go @@ -1,8 +1,6 @@ package p2p import ( - "crypto/ed25519" - "encoding/binary" "fmt" "log" "sort" @@ -50,13 +48,7 @@ func generateKeys(_ *testing.T, numValidators int) []cryptoPocket.PrivateKey { for i := range keys { seedInt := genesisConfigSeedStart + i - seed := make([]byte, ed25519.PrivateKeySize) - binary.LittleEndian.PutUint32(seed, uint32(seedInt)) - pk, err := cryptoPocket.NewPrivateKeyFromSeed(seed) - if err != nil { - panic(err) - } - keys[i] = pk + keys[i] = cryptoPocket.GetPrivKeySeed(seedInt) } sort.Slice(keys, func(i, j int) bool { return keys[i].Address().String() < keys[j].Address().String() diff --git a/runtime/configs/proto/p2p_config.proto b/runtime/configs/proto/p2p_config.proto index d21b56028..171bc2db8 100644 --- a/runtime/configs/proto/p2p_config.proto +++ b/runtime/configs/proto/p2p_config.proto @@ -13,4 +13,5 @@ message P2PConfig { conn.ConnectionType connection_type = 4; uint64 max_mempool_count = 5; // this is used to limit the number of nonces that can be stored in the mempool, after which a FIFO mechanism is used to remove the oldest nonces and make space for the new ones bool is_client_only = 6; + string bootstrap_nodes_csv = 7; // string in the format "http://somenode:50832,http://someothernode:50832". Refer to `p2p/module_test.go` for additional details. } diff --git a/runtime/defaults/defaults.go b/runtime/defaults/defaults.go index 1d2cca21c..b074bddfc 100644 --- a/runtime/defaults/defaults.go +++ b/runtime/defaults/defaults.go @@ -36,6 +36,14 @@ var ( DefaultP2PUseRainTree = true DefaultP2PConnectionType = types.ConnectionType_TCPConnection DefaultP2PMaxMempoolCount = uint64(1e5) + // DefaultP2PBootstrapNodesCsv is a list of nodes to bootstrap the network with. By convention, for now, the first validator will provide bootstrapping facilities. + // + // In LocalNet, the developer will have only one of the two stack online, therefore this is also a poor's man way to simulate the scenario in which a boostrap node is offline. + DefaultP2PBootstrapNodesCsv = fmt.Sprintf("%s,%s", + fmt.Sprintf("http://%s:%s", Validator1EndpointDockerCompose, DefaultRPCPort), + fmt.Sprintf("http://%s:%s", Validator1EndpointK8S, DefaultRPCPort), + ) + // telemetry DefaultTelemetryEnabled = true DefaultTelemetryAddress = "0.0.0.0:9000" diff --git a/runtime/docs/CHANGELOG.md b/runtime/docs/CHANGELOG.md index 257b21af7..4d0a2137f 100644 --- a/runtime/docs/CHANGELOG.md +++ b/runtime/docs/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.0.22] - 2023-02-20 + +- Added `bootstrap_nodes_csv` in `P2PConfig` to allow for a comma separated list of bootstrap nodes + ## [0.0.0.21] - 2023-02-17 - Added validator accounts from the genesis file to the `manager_test.go` diff --git a/runtime/manager.go b/runtime/manager.go index e1e41e4f7..958e68cd2 100644 --- a/runtime/manager.go +++ b/runtime/manager.go @@ -165,3 +165,9 @@ func WithClientDebugMode() func(*Manager) { b.config.ClientDebugMode = true } } + +func WithBootstrapNodes(bootstrapNodesCsv string) func(*Manager) { + return func(b *Manager) { + b.config.P2P.BootstrapNodesCsv = bootstrapNodesCsv + } +} diff --git a/shared/CHANGELOG.md b/shared/CHANGELOG.md index 4e976b6c7..15f12377d 100644 --- a/shared/CHANGELOG.md +++ b/shared/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.0.29] - 2023-02-20 + +- Fan-ing out `StateMachineTransitionEventType` event to the `P2P` module to handle bootstrapping logic +- Refactored single key generation from seed (used in tests) into `GetPrivKeySeed` + ## [0.0.0.28] - 2023-02-17 - Added `UnmarshalText` to `Ed25519PrivateKey` diff --git a/shared/crypto/rand.go b/shared/crypto/rand.go index 8689274be..813cab3b5 100644 --- a/shared/crypto/rand.go +++ b/shared/crypto/rand.go @@ -1,7 +1,9 @@ package crypto import ( + "crypto/ed25519" crand "crypto/rand" + "encoding/binary" "math/big" "math/rand" "time" @@ -21,3 +23,14 @@ func GetNonce() uint64 { } return bigNonce.Uint64() } + +// GetPrivKeySeed returns a private key from a seed +func GetPrivKeySeed(seed int) PrivateKey { + seedBytes := make([]byte, ed25519.PrivateKeySize) + binary.LittleEndian.PutUint32(seedBytes, uint32(seed)) + pk, err := NewPrivateKeyFromSeed(seedBytes) + if err != nil { + panic(err) + } + return pk +} diff --git a/shared/node.go b/shared/node.go index 75597b6d4..971a6ecf0 100644 --- a/shared/node.go +++ b/shared/node.go @@ -142,7 +142,7 @@ func (node *Node) handleEvent(message *messaging.PocketEnvelope) error { return node.GetBus().GetUtilityModule().HandleMessage(message.Content) case messaging.DebugMessageEventType: return node.handleDebugMessage(message) - case messaging.ConsensusNewHeightEventType: + case messaging.ConsensusNewHeightEventType, messaging.StateMachineTransitionEventType: return node.GetBus().GetP2PModule().HandleEvent(message.Content) default: logger.Global.Warn().Msgf("Unsupported message content type: %s", contentType)