diff --git a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/ThreadPantheonNodeRunner.java b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/ThreadPantheonNodeRunner.java index 08bb5a8280..9da28affd0 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/ThreadPantheonNodeRunner.java +++ b/acceptance-tests/src/test/java/tech/pegasys/pantheon/tests/acceptance/dsl/node/ThreadPantheonNodeRunner.java @@ -22,6 +22,7 @@ import tech.pegasys.pantheon.controller.PantheonController; import tech.pegasys.pantheon.ethereum.eth.sync.SynchronizerConfiguration.Builder; import java.io.IOException; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -76,7 +77,8 @@ public class ThreadPantheonNodeRunner implements PantheonNodeRunner { 25, node.jsonRpcConfiguration(), node.webSocketConfiguration(), - node.homeDirectory()); + node.homeDirectory(), + Collections.emptySet()); nodeExecutor.submit(runner::execute); diff --git a/ethereum/p2p/src/main/java/tech/pegasys/pantheon/ethereum/p2p/peers/PeerBlacklist.java b/ethereum/p2p/src/main/java/tech/pegasys/pantheon/ethereum/p2p/peers/PeerBlacklist.java index 4f797d30d2..a0535cdde3 100644 --- a/ethereum/p2p/src/main/java/tech/pegasys/pantheon/ethereum/p2p/peers/PeerBlacklist.java +++ b/ethereum/p2p/src/main/java/tech/pegasys/pantheon/ethereum/p2p/peers/PeerBlacklist.java @@ -25,6 +25,19 @@ import java.util.Set; import com.google.common.collect.ImmutableSet; +/** + * A list of nodes that the running client will not communicate with. This can be because of network + * issues, protocol issues, or by being explicitly set on the command line. + * + *

Peers are stored and identified strictly by their nodeId, the convenience methods taking + * {@link Peer}s and {@link PeerConnection}s redirect to the methods that take {@link BytesValue} + * object that represent the node ID of the banned nodes. + * + *

The storage list is not infinite. A default cap of 500 is applied and nodes are removed on a + * first added first removed basis. Adding a new copy of the same node will not affect the priority + * for removal. The exception to this is a list of banned nodes passed in by reference to the + * constructor. This list neither adds nor removes from that list passed in. + */ public class PeerBlacklist implements DisconnectCallback { private static final int DEFAULT_BLACKLIST_CAP = 500; @@ -46,16 +59,28 @@ public class PeerBlacklist implements DisconnectCallback { } })); - public PeerBlacklist(final int blacklistCap) { + /** These nodes are always banned for the life of this list. They are not subject to rollover. */ + private final Set bannedNodeIds; + + public PeerBlacklist(final int blacklistCap, final Set bannedNodeIds) { this.blacklistCap = blacklistCap; + this.bannedNodeIds = bannedNodeIds; + } + + public PeerBlacklist(final int blacklistCap) { + this(blacklistCap, Collections.emptySet()); + } + + public PeerBlacklist(final Set bannedNodeIds) { + this(DEFAULT_BLACKLIST_CAP, bannedNodeIds); } public PeerBlacklist() { - this(DEFAULT_BLACKLIST_CAP); + this(DEFAULT_BLACKLIST_CAP, Collections.emptySet()); } private boolean contains(final BytesValue nodeId) { - return blacklistedNodeIds.contains(nodeId); + return blacklistedNodeIds.contains(nodeId) || bannedNodeIds.contains(nodeId); } public boolean contains(final PeerConnection peer) { @@ -70,7 +95,7 @@ public class PeerBlacklist implements DisconnectCallback { add(peer.getId()); } - private void add(final BytesValue peerId) { + public void add(final BytesValue peerId) { blacklistedNodeIds.add(peerId); } diff --git a/ethereum/p2p/src/test/java/tech/pegasys/pantheon/ethereum/p2p/peers/PeerBlacklistTest.java b/ethereum/p2p/src/test/java/tech/pegasys/pantheon/ethereum/p2p/peers/PeerBlacklistTest.java index b31244606f..40af78fd87 100644 --- a/ethereum/p2p/src/test/java/tech/pegasys/pantheon/ethereum/p2p/peers/PeerBlacklistTest.java +++ b/ethereum/p2p/src/test/java/tech/pegasys/pantheon/ethereum/p2p/peers/PeerBlacklistTest.java @@ -21,13 +21,80 @@ import tech.pegasys.pantheon.ethereum.p2p.wire.PeerInfo; import tech.pegasys.pantheon.ethereum.p2p.wire.messages.DisconnectMessage.DisconnectReason; import tech.pegasys.pantheon.util.bytes.BytesValue; +import java.util.Collections; + import org.junit.Test; public class PeerBlacklistTest { private int nodeIdValue = 1; @Test - public void doesNotBlacklistPeerForNormalDisconnect() throws Exception { + public void directlyAddingPeerWorks() { + final PeerBlacklist blacklist = new PeerBlacklist(); + final Peer peer = generatePeer(); + + assertThat(blacklist.contains(peer)).isFalse(); + + blacklist.add(peer); + + assertThat(blacklist.contains(peer)).isTrue(); + } + + @Test + public void directlyAddingPeerByPeerIdWorks() { + final PeerBlacklist blacklist = new PeerBlacklist(); + final Peer peer = generatePeer(); + + assertThat(blacklist.contains(peer)).isFalse(); + + blacklist.add(peer.getId()); + + assertThat(blacklist.contains(peer)).isTrue(); + } + + @Test + public void banningPeerByPeerIdWorks() { + final Peer peer = generatePeer(); + final PeerBlacklist blacklist = new PeerBlacklist(Collections.singleton(peer.getId())); + + assertThat(blacklist.contains(peer)).isTrue(); + + blacklist.add(peer.getId()); + + assertThat(blacklist.contains(peer)).isTrue(); + } + + @Test + public void bannedNodesDoNotRollover() { + final Peer bannedPeer = generatePeer(); + final Peer peer1 = generatePeer(); + final Peer peer2 = generatePeer(); + final Peer peer3 = generatePeer(); + final PeerBlacklist blacklist = new PeerBlacklist(2, Collections.singleton(bannedPeer.getId())); + + assertThat(blacklist.contains(bannedPeer)).isTrue(); + assertThat(blacklist.contains(peer1)).isFalse(); + assertThat(blacklist.contains(peer2)).isFalse(); + assertThat(blacklist.contains(peer3)).isFalse(); + + // fill to the limit + blacklist.add(peer1.getId()); + blacklist.add(peer2.getId()); + assertThat(blacklist.contains(bannedPeer)).isTrue(); + assertThat(blacklist.contains(peer1)).isTrue(); + assertThat(blacklist.contains(peer2)).isTrue(); + assertThat(blacklist.contains(peer3)).isFalse(); + + // trigger rollover + blacklist.add(peer3.getId()); + assertThat(blacklist.contains(bannedPeer)).isTrue(); + assertThat(blacklist.contains(peer1)).isFalse(); + assertThat(blacklist.contains(peer2)).isTrue(); + assertThat(blacklist.contains(peer3)).isTrue(); + } + + @Test + public void doesNotBlacklistPeerForNormalDisconnect() { final PeerBlacklist blacklist = new PeerBlacklist(); final PeerConnection peer = generatePeerConnection(); @@ -39,7 +106,7 @@ public class PeerBlacklistTest { } @Test - public void blacklistPeerForBadBehavior() throws Exception { + public void blacklistPeerForBadBehavior() { final PeerBlacklist blacklist = new PeerBlacklist(); final PeerConnection peer = generatePeerConnection(); @@ -52,7 +119,7 @@ public class PeerBlacklistTest { } @Test - public void doesNotBlacklistPeerForOurBadBehavior() throws Exception { + public void doesNotBlacklistPeerForOurBadBehavior() { final PeerBlacklist blacklist = new PeerBlacklist(); final PeerConnection peer = generatePeerConnection(); @@ -64,7 +131,7 @@ public class PeerBlacklistTest { } @Test - public void blacklistIncompatiblePeer() throws Exception { + public void blacklistIncompatiblePeer() { final PeerBlacklist blacklist = new PeerBlacklist(); final PeerConnection peer = generatePeerConnection(); @@ -76,7 +143,7 @@ public class PeerBlacklistTest { } @Test - public void blacklistIncompatiblePeerWhoIssuesDisconnect() throws Exception { + public void blacklistIncompatiblePeerWhoIssuesDisconnect() { final PeerBlacklist blacklist = new PeerBlacklist(); final PeerConnection peer = generatePeerConnection(); @@ -88,7 +155,7 @@ public class PeerBlacklistTest { } @Test - public void capsSizeOfList() throws Exception { + public void capsSizeOfList() { final PeerBlacklist blacklist = new PeerBlacklist(2); final PeerConnection peer1 = generatePeerConnection(); @@ -136,4 +203,10 @@ public class PeerBlacklistTest { return peer; } + + private Peer generatePeer() { + final byte[] id = new byte[64]; + id[0] = (byte) nodeIdValue++; + return new DefaultPeer(BytesValue.wrap(id), "10.9.8.7", 65535, 65534); + } } diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/RunnerBuilder.java b/pantheon/src/main/java/tech/pegasys/pantheon/RunnerBuilder.java index b01d73c3fe..0ff1c0f472 100644 --- a/pantheon/src/main/java/tech/pegasys/pantheon/RunnerBuilder.java +++ b/pantheon/src/main/java/tech/pegasys/pantheon/RunnerBuilder.java @@ -54,6 +54,7 @@ import tech.pegasys.pantheon.ethereum.p2p.netty.NettyP2PNetwork; import tech.pegasys.pantheon.ethereum.p2p.peers.PeerBlacklist; import tech.pegasys.pantheon.ethereum.p2p.wire.Capability; import tech.pegasys.pantheon.ethereum.p2p.wire.SubProtocol; +import tech.pegasys.pantheon.util.bytes.BytesValue; import java.nio.file.Path; import java.util.Collection; @@ -78,7 +79,8 @@ public class RunnerBuilder { final int maxPeers, final JsonRpcConfiguration jsonRpcConfiguration, final WebSocketConfiguration webSocketConfiguration, - final Path dataDir) { + final Path dataDir, + final Collection bannedNodeIds) { Preconditions.checkNotNull(pantheonController); @@ -122,6 +124,10 @@ public class RunnerBuilder { .setClientId(PantheonInfo.version()) .setSupportedProtocols(subProtocols); + final PeerBlacklist peerBlacklist = + new PeerBlacklist( + bannedNodeIds.stream().map(BytesValue::fromHexString).collect(Collectors.toSet())); + final NetworkRunner networkRunner = NetworkRunner.builder() .protocolManagers(protocolManagers) @@ -134,7 +140,7 @@ public class RunnerBuilder { networkConfig, caps, PeerRequirement.aggregateOf(protocolManagers), - new PeerBlacklist())) + peerBlacklist)) .build(); final Synchronizer synchronizer = pantheonController.getSynchronizer(); diff --git a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java index a8793a891f..4cf475d4e3 100644 --- a/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java +++ b/pantheon/src/main/java/tech/pegasys/pantheon/cli/PantheonCommand.java @@ -44,6 +44,7 @@ import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.function.Function; @@ -199,6 +200,14 @@ public class PantheonCommand implements Runnable { ) private final Integer maxTrailingPeers = Integer.MAX_VALUE; + @Option( + names = {"--banned-nodeids"}, + description = "A list of node IDs to ban from the p2p network.", + split = ",", + arity = "1..*" + ) + private final Collection bannedNodeIds = null; + // TODO: Re-enable as per NC-1057/NC-1681 // @Option( // names = {"--sync-mode"}, @@ -491,7 +500,8 @@ public class PantheonCommand implements Runnable { maxPeers, jsonRpcConfiguration, webSocketConfiguration, - dataDir); + dataDir, + bannedNodeIds == null ? Collections.emptySet() : bannedNodeIds); addShutdownHook(runner); runner.execute(); diff --git a/pantheon/src/test/java/tech/pegasys/pantheon/RunnerTest.java b/pantheon/src/test/java/tech/pegasys/pantheon/RunnerTest.java index 3984adfb81..b5268761cf 100644 --- a/pantheon/src/test/java/tech/pegasys/pantheon/RunnerTest.java +++ b/pantheon/src/test/java/tech/pegasys/pantheon/RunnerTest.java @@ -127,7 +127,8 @@ public final class RunnerTest { 3, aheadJsonRpcConfiguration, aheadWebSocketConfiguration, - dbAhead); + dbAhead, + Collections.emptySet()); try { executorService.submit(runnerAhead::execute); @@ -161,7 +162,8 @@ public final class RunnerTest { 3, behindJsonRpcConfiguration, behindWebSocketConfiguration, - dbBehind); + dbBehind, + Collections.emptySet()); executorService.submit(runnerBehind::execute); final Call.Factory client = new OkHttpClient(); diff --git a/pantheon/src/test/java/tech/pegasys/pantheon/cli/PantheonCommandTest.java b/pantheon/src/test/java/tech/pegasys/pantheon/cli/PantheonCommandTest.java index 9e0f982646..d15ad9f454 100644 --- a/pantheon/src/test/java/tech/pegasys/pantheon/cli/PantheonCommandTest.java +++ b/pantheon/src/test/java/tech/pegasys/pantheon/cli/PantheonCommandTest.java @@ -93,6 +93,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), any(), any(), + any(), any())) .thenReturn(mockRunner); } @@ -135,6 +136,7 @@ public class PantheonCommandTest extends CommandTestAbstract { eq(25), eq(defaultJsonRpcConfiguration), eq(defaultWebSocketConfiguration), + any(), any()); final ArgumentCaptor miningArg = @@ -262,6 +264,7 @@ public class PantheonCommandTest extends CommandTestAbstract { eq(42), eq(jsonRpcConfiguration), eq(webSocketConfiguration), + any(), any()); final Collection nodes = @@ -315,6 +318,7 @@ public class PantheonCommandTest extends CommandTestAbstract { eq(25), eq(jsonRpcConfiguration), eq(webSocketConfiguration), + any(), any()); verify(mockControllerBuilder).build(any(), any(), any(), eq(false), any(), eq(false)); @@ -368,7 +372,17 @@ public class PantheonCommandTest extends CommandTestAbstract { verify(mockRunnerBuilder) .build( - any(), any(), eq(false), any(), anyString(), anyInt(), anyInt(), any(), any(), any()); + any(), + any(), + eq(false), + any(), + anyString(), + anyInt(), + anyInt(), + any(), + any(), + any(), + any()); assertThat(commandOutput.toString()).isEmpty(); assertThat(commandErrorOutput.toString()).isEmpty(); @@ -383,6 +397,15 @@ public class PantheonCommandTest extends CommandTestAbstract { assertThat(commandErrorOutput.toString()).startsWith(expectedErrorOutputStart); } + @Test + public void callingWithBannedNodeidsOptionButNoValueMustDisplayErrorAndUsage() { + parseCommand("--banned-nodeids"); + assertThat(commandOutput.toString()).isEmpty(); + final String expectedErrorOutputStart = + "Missing required parameter for option '--banned-nodeids' at index 0 ()"; + assertThat(commandErrorOutput.toString()).startsWith(expectedErrorOutputStart); + } + @Test public void bootnodesOptionMustBeUsed() { final String[] nodes = {"enode://001@123:4567", "enode://002@123:4567", "enode://003@123:4567"}; @@ -399,6 +422,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), any(), any(), + any(), any()); assertThat(stringListArgumentCaptor.getValue().toArray()).isEqualTo(nodes); @@ -407,6 +431,31 @@ public class PantheonCommandTest extends CommandTestAbstract { assertThat(commandErrorOutput.toString()).isEmpty(); } + @Test + public void banNodeIdsOptionMustBeUsed() { + final String[] nodes = {"0001", "0002", "0003"}; + parseCommand("--banned-nodeids", String.join(",", nodes)); + + verify(mockRunnerBuilder) + .build( + any(), + any(), + anyBoolean(), + any(), + anyString(), + anyInt(), + anyInt(), + any(), + any(), + any(), + stringListArgumentCaptor.capture()); + + assertThat(stringListArgumentCaptor.getValue().toArray()).isEqualTo(nodes); + + assertThat(commandOutput.toString()).isEmpty(); + assertThat(commandErrorOutput.toString()).isEmpty(); + } + @Test public void p2pHostAndPortOptionMustBeUsed() { @@ -425,6 +474,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), any(), any(), + any(), any()); assertThat(stringArgumentCaptor.getValue()).isEqualTo(host); @@ -451,6 +501,7 @@ public class PantheonCommandTest extends CommandTestAbstract { intArgumentCaptor.capture(), any(), any(), + any(), any()); assertThat(intArgumentCaptor.getValue()).isEqualTo(maxPeers); @@ -497,6 +548,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), jsonRpcConfigArgumentCaptor.capture(), any(), + any(), any()); assertThat(jsonRpcConfigArgumentCaptor.getValue().isEnabled()).isFalse(); @@ -520,6 +572,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), jsonRpcConfigArgumentCaptor.capture(), any(), + any(), any()); assertThat(jsonRpcConfigArgumentCaptor.getValue().isEnabled()).isTrue(); @@ -543,6 +596,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), jsonRpcConfigArgumentCaptor.capture(), any(), + any(), any()); assertThat(jsonRpcConfigArgumentCaptor.getValue().getRpcApis()) @@ -570,6 +624,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), jsonRpcConfigArgumentCaptor.capture(), any(), + any(), any()); assertThat(jsonRpcConfigArgumentCaptor.getValue().getHost()).isEqualTo(host); @@ -595,6 +650,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), jsonRpcConfigArgumentCaptor.capture(), any(), + any(), any()); assertThat(jsonRpcConfigArgumentCaptor.getValue().getCorsAllowedDomains().toArray()) @@ -620,6 +676,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), jsonRpcConfigArgumentCaptor.capture(), any(), + any(), any()); assertThat(jsonRpcConfigArgumentCaptor.getValue().getCorsAllowedDomains().toArray()) @@ -645,6 +702,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), jsonRpcConfigArgumentCaptor.capture(), any(), + any(), any()); assertThat(jsonRpcConfigArgumentCaptor.getValue().getCorsAllowedDomains()) @@ -670,6 +728,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), jsonRpcConfigArgumentCaptor.capture(), any(), + any(), any()); assertThat(jsonRpcConfigArgumentCaptor.getValue().getCorsAllowedDomains()).isEmpty(); @@ -741,6 +800,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), any(), wsRpcConfigArgumentCaptor.capture(), + any(), any()); assertThat(wsRpcConfigArgumentCaptor.getValue().isEnabled()).isFalse(); @@ -764,6 +824,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), any(), wsRpcConfigArgumentCaptor.capture(), + any(), any()); assertThat(wsRpcConfigArgumentCaptor.getValue().isEnabled()).isTrue(); @@ -787,6 +848,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), any(), wsRpcConfigArgumentCaptor.capture(), + any(), any()); assertThat(wsRpcConfigArgumentCaptor.getValue().getRpcApis()) @@ -813,6 +875,7 @@ public class PantheonCommandTest extends CommandTestAbstract { anyInt(), any(), wsRpcConfigArgumentCaptor.capture(), + any(), any()); assertThat(wsRpcConfigArgumentCaptor.getValue().getHost()).isEqualTo(host);