From f9da6584e1631d439907e4334c2646a3d906e050 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:48:37 +0100 Subject: [PATCH 1/3] feat(p2p): allowlist-only limits connection only to allowed nodes --- config/config.go | 8 ++++ config/config_test.go | 8 ++++ config/toml.go | 4 ++ docs/nodes/configuration.md | 1 + node/node.go | 83 +++++++++++++++++++++++++++++++------ node/node_test.go | 36 ++++++++++++++++ node/setup.go | 7 +++- 7 files changed, 133 insertions(+), 14 deletions(-) diff --git a/config/config.go b/config/config.go index a6e4903fa9..fa57033216 100644 --- a/config/config.go +++ b/config/config.go @@ -738,6 +738,10 @@ type P2PConfig struct { //nolint: maligned // Comma separated list of nodes to keep persistent connections to PersistentPeers string `mapstructure:"persistent-peers"` + // If true, only peers from persistent-peers and bootstrap-peers are allowed + // to connect (inbound and outbound). + AllowlistOnly bool `mapstructure:"allowlist-only"` + // UPNP port forwarding UPNP bool `mapstructure:"upnp"` @@ -812,6 +816,7 @@ func DefaultP2PConfig() *P2PConfig { HandshakeTimeout: 20 * time.Second, DialTimeout: 3 * time.Second, QueueType: "simple-priority", + AllowlistOnly: false, } } @@ -839,6 +844,9 @@ func (cfg *P2PConfig) ValidateBasic() error { if cfg.IncomingConnectionWindow < 1*time.Millisecond { return errors.New("incoming-connection-window must be set to at least 1ms") } + if cfg.AllowlistOnly && strings.TrimSpace(cfg.PersistentPeers) == "" && strings.TrimSpace(cfg.BootstrapPeers) == "" { + return errors.New("allowlist-only requires at least one of persistent-peers or bootstrap-peers") + } return nil } diff --git a/config/config_test.go b/config/config_test.go index 03c70fe81c..0b5a8a46f9 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -223,6 +223,14 @@ func TestP2PConfigValidateBasic(t *testing.T) { assert.Error(t, cfg.ValidateBasic()) reflect.ValueOf(cfg).Elem().FieldByName(fieldName).SetInt(0) } + + cfg.AllowlistOnly = true + cfg.PersistentPeers = "" + cfg.BootstrapPeers = "" + assert.Error(t, cfg.ValidateBasic()) + + cfg.PersistentPeers = "id@127.0.0.1:26656" + assert.NoError(t, cfg.ValidateBasic()) } // Given some invalid node key file, when I try to load it, I get an error diff --git a/config/toml.go b/config/toml.go index dddf0ac43f..d3b7d20e6d 100644 --- a/config/toml.go +++ b/config/toml.go @@ -346,6 +346,10 @@ bootstrap-peers = "{{ .P2P.BootstrapPeers }}" # Comma separated list of nodes to keep persistent connections to persistent-peers = "{{ .P2P.PersistentPeers }}" +# If true, only peers from persistent-peers and bootstrap-peers are allowed +# to connect (inbound and outbound). +allowlist-only = {{ .P2P.AllowlistOnly }} + # UPNP port forwarding upnp = {{ .P2P.UPNP }} diff --git a/docs/nodes/configuration.md b/docs/nodes/configuration.md index 2a7c33e8ad..3c88f7503a 100644 --- a/docs/nodes/configuration.md +++ b/docs/nodes/configuration.md @@ -521,6 +521,7 @@ This section will cover settings within the p2p section of the `config.toml`. - > We recommend setting an external address. When used in a private network, Tendermint Core currently doesn't advertise the node's public address. There is active and ongoing work to improve the P2P system, but this is a helpful workaround for now. - `persistent-peers` = is a list of comma separated peers that you will always want to be connected to. If you're already connected to the maximum number of peers, persistent peers will not be added. - `pex` = turns the peer exchange reactor on or off. Validator node will want the `pex` turned off so it would not begin gossiping to unknown peers on the network. PeX can also be turned off for statically configured networks with fixed network connectivity. For full nodes on open, dynamic networks, it should be turned on. +- `allowlist-only` = if true, only peers from `persistent-peers` and `bootstrap-peers` are allowed to connect (inbound and outbound). - `private-peer-ids` = is a comma-separated list of node ids that will _not_ be exposed to other peers (i.e., you will not tell other peers about the ids in this list). This can be filled with a validator's node id. Recently the Tendermint Team conducted a refactor of the p2p layer. This lead to multiple config parameters being deprecated and/or replaced. diff --git a/node/node.go b/node/node.go index e464504249..d7d53df1f9 100644 --- a/node/node.go +++ b/node/node.go @@ -5,7 +5,6 @@ import ( "fmt" "net" "net/http" - "strconv" "strings" "time" @@ -21,6 +20,7 @@ import ( "github.com/dashpay/tenderdash/internal/eventbus" "github.com/dashpay/tenderdash/internal/eventlog" "github.com/dashpay/tenderdash/internal/evidence" + tmstrings "github.com/dashpay/tenderdash/internal/libs/strings" "github.com/dashpay/tenderdash/internal/mempool" "github.com/dashpay/tenderdash/internal/p2p" p2pclient "github.com/dashpay/tenderdash/internal/p2p/client" @@ -737,7 +737,7 @@ func loadStateFromDBOrGenesisDocProvider(stateStore sm.Store, genDoc *types.Gene return state, nil } -func getRouterConfig(conf *config.Config, appClient abciclient.Client) p2p.RouterOptions { +func getRouterConfig(conf *config.Config, appClient abciclient.Client) (p2p.RouterOptions, error) { opts := p2p.RouterOptions{ QueueType: conf.P2P.QueueType, HandshakeTimeout: conf.P2P.HandshakeTimeout, @@ -745,8 +745,24 @@ func getRouterConfig(conf *config.Config, appClient abciclient.Client) p2p.Route IncomingConnectionWindow: conf.P2P.IncomingConnectionWindow, } + var filterByID func(context.Context, types.NodeID) error + + if conf.P2P.AllowlistOnly { + allowedIDs, err := buildAllowlist(conf) + if err != nil { + return p2p.RouterOptions{}, err + } + + filterByID = func(_ context.Context, id types.NodeID) error { + if _, ok := allowedIDs[id]; !ok { + return fmt.Errorf("peer %s is not in allowlist", id) + } + return nil + } + } + if conf.FilterPeers && appClient != nil { - opts.FilterPeerByID = func(ctx context.Context, id types.NodeID) error { + abciFilterByID := func(ctx context.Context, id types.NodeID) error { res, err := appClient.Query(ctx, &abci.RequestQuery{ Path: fmt.Sprintf("/p2p/filter/id/%s", id), }) @@ -760,23 +776,64 @@ func getRouterConfig(conf *config.Config, appClient abciclient.Client) p2p.Route return nil } - opts.FilterPeerByIP = func(ctx context.Context, ip net.IP, port uint16) error { - res, err := appClient.Query(ctx, &abci.RequestQuery{ - Path: fmt.Sprintf("/p2p/filter/addr/%s", net.JoinHostPort(ip.String(), strconv.Itoa(int(port)))), - }) - if err != nil { + filterByID = chainFilterByID(filterByID, abciFilterByID) + } + + opts.FilterPeerByID = filterByID + + return opts, nil +} + +func chainFilterByID(filters ...func(context.Context, types.NodeID) error) func(context.Context, types.NodeID) error { + var chained []func(context.Context, types.NodeID) error + for _, f := range filters { + if f != nil { + chained = append(chained, f) + } + } + if len(chained) == 0 { + return nil + } + + return func(ctx context.Context, id types.NodeID) error { + for _, f := range chained { + if err := f(ctx, id); err != nil { return err } - if res.IsErr() { - return fmt.Errorf("error querying abci app: %v", res) - } + } + return nil + } +} - return nil +func buildAllowlist(conf *config.Config) (map[types.NodeID]struct{}, error) { + allowedIDs := make(map[types.NodeID]struct{}) + + addPeer := func(p string) error { + address, err := p2p.ParseNodeAddress(p) + if err != nil { + return fmt.Errorf("invalid allowlist peer address %q: %w", p, err) } + if address.NodeID != "" { + allowedIDs[address.NodeID] = struct{}{} + } + + return nil + } + + for _, p := range tmstrings.SplitAndTrimEmpty(conf.P2P.PersistentPeers, ",", " ") { + if err := addPeer(p); err != nil { + return nil, err + } + } + + for _, p := range tmstrings.SplitAndTrimEmpty(conf.P2P.BootstrapPeers, ",", " ") { + if err := addPeer(p); err != nil { + return nil, err + } } - return opts + return allowedIDs, nil } // DefaultDashCoreRPCClient returns RPC client for the Dash Core node. diff --git a/node/node_test.go b/node/node_test.go index 35d63cbb57..52d7571ce0 100644 --- a/node/node_test.go +++ b/node/node_test.go @@ -7,6 +7,7 @@ import ( "math" "net" "os" + "strings" "testing" "time" @@ -139,6 +140,41 @@ func TestNodeDelayedStart(t *testing.T) { assert.Equal(t, true, startTime.After(n.GenesisDoc().GenesisTime)) } +func TestGetRouterConfigAllowlistOnlyFiltersByIDAndIP(t *testing.T) { + cfg := config.TestConfig() + cfg.P2P.AllowlistOnly = true + + id1 := types.NodeID(strings.Repeat("a", 40)) + id2 := types.NodeID(strings.Repeat("b", 40)) + id3 := types.NodeID(strings.Repeat("c", 40)) + + cfg.P2P.PersistentPeers = fmt.Sprintf("tcp://%s@127.0.0.1:26656", id1) + cfg.P2P.BootstrapPeers = fmt.Sprintf("tcp://%s@127.0.0.2:26656", id2) + + opts, err := getRouterConfig(cfg, nil) + require.NoError(t, err) + require.NotNil(t, opts.FilterPeerByID) + + require.NoError(t, opts.FilterPeerByID(context.Background(), id1)) + require.NoError(t, opts.FilterPeerByID(context.Background(), id2)) + require.Error(t, opts.FilterPeerByID(context.Background(), id3)) +} + +func TestGetRouterConfigAllowlistOnlyAcceptsOnlyNodeIDs(t *testing.T) { + cfg := config.TestConfig() + cfg.P2P.AllowlistOnly = true + + id1 := types.NodeID(strings.Repeat("a", 40)) + cfg.P2P.PersistentPeers = fmt.Sprintf("tcp:%s", id1) + + opts, err := getRouterConfig(cfg, nil) + require.NoError(t, err) + require.NotNil(t, opts.FilterPeerByID) + + require.NoError(t, opts.FilterPeerByID(context.Background(), id1)) + require.Error(t, opts.FilterPeerByID(context.Background(), types.NodeID(strings.Repeat("b", 40)))) +} + func TestNodeSetAppVersion(t *testing.T) { cfg, err := config.ResetTestRoot(t.TempDir(), t.Name()) require.NoError(t, err) diff --git a/node/setup.go b/node/setup.go index 3553a24d92..0ec5a4e5f7 100644 --- a/node/setup.go +++ b/node/setup.go @@ -329,6 +329,11 @@ func createRouter( return nil, err } + opts, err := getRouterConfig(cfg, appClient) + if err != nil { + return nil, err + } + return p2p.NewRouter( p2pLogger, p2pMetrics, @@ -337,7 +342,7 @@ func createRouter( nodeInfoProducer, transport, ep, - getRouterConfig(cfg, appClient), + opts, ) } From 234224f9c3a06e95243aabc480a9d96e8554fbdd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:59:46 +0100 Subject: [PATCH 2/3] chore: improve error handling --- node/node.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/node/node.go b/node/node.go index d7d53df1f9..7fc75f8357 100644 --- a/node/node.go +++ b/node/node.go @@ -833,6 +833,15 @@ func buildAllowlist(conf *config.Config) (map[types.NodeID]struct{}, error) { } } + if len(allowedIDs) == 0 { + return nil, fmt.Errorf( + "allowlist-only enabled but no NodeIDs found in persistent-peers or bootstrap-peers; "+ + "include NodeID@host:port entries or disable allowlist-only (persistent-peers=%q bootstrap-peers=%q)", + conf.P2P.PersistentPeers, + conf.P2P.BootstrapPeers, + ) + } + return allowedIDs, nil } From d96dbfea8f691016b3ff4bb9f4c7997ec6ad921f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:17:11 +0100 Subject: [PATCH 3/3] chore: test rename --- node/node_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/node_test.go b/node/node_test.go index 52d7571ce0..0c9604f7de 100644 --- a/node/node_test.go +++ b/node/node_test.go @@ -140,7 +140,7 @@ func TestNodeDelayedStart(t *testing.T) { assert.Equal(t, true, startTime.After(n.GenesisDoc().GenesisTime)) } -func TestGetRouterConfigAllowlistOnlyFiltersByIDAndIP(t *testing.T) { +func TestGetRouterConfigAllowlistOnlyFilters(t *testing.T) { cfg := config.TestConfig() cfg.P2P.AllowlistOnly = true