diff --git a/CHANGELOG.md b/CHANGELOG.md index a974944bf03..3e13ba47ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,5 +9,6 @@ ### Breaking Changes ### Additions and Improvements +- Added `--p2p-static-peers-url` option to read static peers from a URL or file ### Bug Fixes \ No newline at end of file diff --git a/acceptance-tests/src/testFixtures/java/tech/pegasys/teku/test/acceptance/dsl/TekuNodeConfigBuilder.java b/acceptance-tests/src/testFixtures/java/tech/pegasys/teku/test/acceptance/dsl/TekuNodeConfigBuilder.java index e21ecdd8856..dfe7442ddfa 100644 --- a/acceptance-tests/src/testFixtures/java/tech/pegasys/teku/test/acceptance/dsl/TekuNodeConfigBuilder.java +++ b/acceptance-tests/src/testFixtures/java/tech/pegasys/teku/test/acceptance/dsl/TekuNodeConfigBuilder.java @@ -559,6 +559,13 @@ public TekuNodeConfigBuilder withPeers(final TekuBeaconNode... nodes) { return this; } + public TekuNodeConfigBuilder withPeersUrl(final String peersUrl) { + mustBe(NodeType.BEACON_NODE); + LOG.debug("p2p-static-peers-url={}", peersUrl); + configMap.put("p2p-static-peers-url", peersUrl); + return this; + } + public TekuNodeConfigBuilder withExternalSignerUrl(final String externalSignerUrl) { LOG.debug("validators-external-signer-url={}", externalSignerUrl); configMap.put("validators-external-signer-url", externalSignerUrl); diff --git a/teku/src/main/java/tech/pegasys/teku/cli/options/P2POptions.java b/teku/src/main/java/tech/pegasys/teku/cli/options/P2POptions.java index b4dfa9792fd..7d6e69ef47c 100644 --- a/teku/src/main/java/tech/pegasys/teku/cli/options/P2POptions.java +++ b/teku/src/main/java/tech/pegasys/teku/cli/options/P2POptions.java @@ -20,16 +20,23 @@ import static tech.pegasys.teku.networking.p2p.discovery.DiscoveryConfig.DEFAULT_P2P_PEERS_UPPER_BOUND_ALL_SUBNETS; import static tech.pegasys.teku.validator.api.ValidatorConfig.DEFAULT_EXECUTOR_MAX_QUEUE_SIZE_ALL_SUBNETS; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.OptionalInt; +import java.util.stream.Collectors; import picocli.CommandLine.Help.Visibility; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; import tech.pegasys.teku.beacon.sync.SyncConfig; import tech.pegasys.teku.cli.converter.OptionalIntConverter; import tech.pegasys.teku.config.TekuConfiguration; +import tech.pegasys.teku.infrastructure.exceptions.InvalidConfigurationException; +import tech.pegasys.teku.infrastructure.io.resource.ResourceLoader; import tech.pegasys.teku.networking.eth2.P2PConfig; import tech.pegasys.teku.networking.p2p.discovery.DiscoveryConfig; import tech.pegasys.teku.networking.p2p.gossip.config.GossipConfig; @@ -209,14 +216,22 @@ The network interface(s) on which the node listens for P2P communication. hidden = true) private Integer minimumRandomlySelectedPeerCount; + @Option( + names = {"--p2p-static-peers-url"}, + paramLabel = "", + description = + "Specifies a URL or file containing a list of 'static' peers (one per line) with which to establish and maintain connections. Accepts multiaddr format.", + arity = "1") + private String p2pStaticPeersUrl; + @Option( names = {"--p2p-static-peers"}, paramLabel = "", description = - "Specifies a list of 'static' peers with which to establish and maintain connections", + "Specifies a comma-separated list of 'static' peers with which to establish and maintain connections. Accepts multiaddr format.", split = ",", - arity = "0..*") - private List p2pStaticPeers = new ArrayList<>(); + arity = "1") + private final List p2pStaticPeers = new ArrayList<>(); @Option( names = {"--p2p-direct-peers"}, @@ -453,6 +468,38 @@ private OptionalInt getP2pUpperBound() { return p2pUpperBound; } + private List getStaticPeersList() { + final List staticPeers = new ArrayList<>(p2pStaticPeers); + + if (p2pStaticPeersUrl != null) { + try { + final Optional maybeStream = + ResourceLoader.urlOrFile().load(p2pStaticPeersUrl); + if (maybeStream.isPresent()) { + try (final BufferedReader reader = + new BufferedReader( + new InputStreamReader(maybeStream.get(), StandardCharsets.UTF_8))) { + final List peersFromUrl = + reader + .lines() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .collect(Collectors.toList()); + staticPeers.addAll(peersFromUrl); + } + } else { + throw new InvalidConfigurationException( + String.format("Static peers URL not found: %s", p2pStaticPeersUrl)); + } + } catch (Exception e) { + throw new InvalidConfigurationException( + String.format("Failed to read static peers from URL: %s", p2pStaticPeersUrl), e); + } + } + + return staticPeers; + } + public void configure(final TekuConfiguration.Builder builder) { // From a discovery configuration perspective, direct peers are static peers p2pStaticPeers.addAll(p2pDirectPeers); @@ -507,7 +554,7 @@ public void configure(final TekuConfiguration.Builder builder) { d.advertisedUdpPortIpv6(OptionalInt.of(p2pAdvertisedPortIpv6)); } d.isDiscoveryEnabled(p2pDiscoveryEnabled) - .staticPeers(p2pStaticPeers) + .staticPeers(getStaticPeersList()) .siteLocalAddressesEnabled(siteLocalAddressesEnabled); }) .network( diff --git a/teku/src/test/java/tech/pegasys/teku/cli/options/P2POptionsTest.java b/teku/src/test/java/tech/pegasys/teku/cli/options/P2POptionsTest.java index 129434276e6..9652d99cb60 100644 --- a/teku/src/test/java/tech/pegasys/teku/cli/options/P2POptionsTest.java +++ b/teku/src/test/java/tech/pegasys/teku/cli/options/P2POptionsTest.java @@ -15,6 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertEquals; import static tech.pegasys.teku.infrastructure.async.AsyncRunnerFactory.DEFAULT_MAX_QUEUE_SIZE_ALL_SUBNETS; import static tech.pegasys.teku.networking.eth2.P2PConfig.DEFAULT_GOSSIP_BLOBS_AFTER_BLOCK_ENABLED; @@ -27,8 +28,18 @@ import static tech.pegasys.teku.validator.api.ValidatorConfig.DEFAULT_EXECUTOR_MAX_QUEUE_SIZE_ALL_SUBNETS; import com.google.common.base.Supplier; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import tech.pegasys.teku.beacon.sync.SyncConfig; import tech.pegasys.teku.cli.AbstractBeaconNodeCommandTest; import tech.pegasys.teku.config.TekuConfiguration; @@ -79,6 +90,23 @@ public void shouldReadFromConfigurationFile() { assertThat(syncConfig.getForwardSyncMaxBlobSidecarsPerMinute()).isEqualTo(400); } + @Test + public void shouldReadUrlFromConfigurationFile(@TempDir final Path tempDir) throws Exception { + final Path peersFile = tempDir.resolve("peers.txt"); + final Path configPath = tempDir.resolve("config.yaml"); + Files.writeString(peersFile, "\n\n127.0.1.1\n127.1.1.1\n", StandardCharsets.UTF_8); + Files.writeString( + configPath, + String.format("p2p-static-peers-url: \"%s\"", peersFile.toAbsolutePath()), + StandardCharsets.UTF_8); + + final TekuConfiguration tekuConfig = + getTekuConfigurationFromArguments("--config-file", configPath.toAbsolutePath().toString()); + + final DiscoveryConfig discoConfig = tekuConfig.discovery(); + assertThat(discoConfig.getStaticPeers()).isEqualTo(List.of("127.0.1.1", "127.1.1.1")); + } + @Test public void p2pEnabled_shouldNotRequireAValue() { final TekuConfiguration config = getTekuConfigurationFromArguments("--p2p-enabled"); @@ -472,4 +500,96 @@ public void defaultPortsAreSetCorrectly() { assertThat(networkConfig.getAdvertisedPort()).isEqualTo(DEFAULT_P2P_PORT); assertThat(networkConfig.getAdvertisedPortIpv6()).isEqualTo(DEFAULT_P2P_PORT_IPV6); } + + @Test + public void staticPeersUrl_shouldReadPeersFromUrl(@TempDir final Path tempDir) throws Exception { + // Create a test file with peers + final Path peersFile = tempDir.resolve("static-peers.txt"); + Files.writeString(peersFile, "peer1\npeer2\n#comment\n\npeer3"); + + final TekuConfiguration tekuConfiguration = + getTekuConfigurationFromArguments( + "--p2p-static-peers-url", peersFile.toAbsolutePath().toString()); + + assertThat(tekuConfiguration.discovery().getStaticPeers()) + .containsExactlyInAnyOrder("peer1", "peer2", "peer3"); + } + + @Test + public void staticPeersUrl_shouldCombineWithCommandLinePeers(@TempDir final Path tempDir) + throws Exception { + // Create a test file with peers + final Path peersFile = tempDir.resolve("static-peers.txt"); + Files.writeString(peersFile, "peer1\npeer2"); + + final TekuConfiguration tekuConfiguration = + getTekuConfigurationFromArguments( + "--p2p-static-peers-url", + peersFile.toAbsolutePath().toString(), + "--p2p-static-peers", + "peer3,peer4"); + + assertThat(tekuConfiguration.discovery().getStaticPeers()) + .containsExactlyInAnyOrder("peer1", "peer2", "peer3", "peer4"); + } + + @ParameterizedTest + @MethodSource("peerBoundsTestParameters") + public void shouldSmartDefaultPeerBounds( + final String lowerIn, + final String upperIn, + final int lowerExpected, + final int upperExpected) { + final TekuConfiguration tekuConfiguration = + getTekuConfigurationFromArguments( + "--p2p-peer-upper-bound", upperIn, "--p2p-peer-lower-bound", lowerIn); + + assertThat(tekuConfiguration.discovery().getMinPeers()).isEqualTo(lowerExpected); + assertThat(tekuConfiguration.discovery().getMaxPeers()).isEqualTo(upperExpected); + } + + private static Stream peerBoundsTestParameters() { + return Stream.of( + Arguments.of("100", "200", 100, 200), + Arguments.of("100", "100", 100, 100), + Arguments.of("0", "0", 0, 0), + Arguments.of("1024000", "1024000", 1024000, 1024000), + Arguments.of("200", "100", 100, 200)); + } + + @Test + public void peersLowerBound_mustNotBeNegative() { + assertThatThrownBy(() -> getTekuConfigurationFromArguments("--p2p-peer-lower-bound", "-1")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("Invalid minPeers: -1"); + } + + @Test + public void peersUpperBound_mustNotBeNegative() { + assertThatThrownBy(() -> getTekuConfigurationFromArguments("--p2p-peer-upper-bound", "-1")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("Invalid maxPeers: -1"); + } + + @Test + public void staticPeersUrl_shouldThrowIfUrlDoesNotExist() { + // Create a dummy instance of P2POptions + final P2POptions p2pOptions = new P2POptions(); + + // Use reflection to access a private field and set its value + try { + final Field field = P2POptions.class.getDeclaredField("p2pStaticPeersUrl"); + field.setAccessible(true); + field.set(p2pOptions, "/non/existent/file.txt"); + + // Use reflection to call a private method getStaticPeersList + final Method method = P2POptions.class.getDeclaredMethod("getStaticPeersList"); + method.setAccessible(true); + + assertThatThrownBy(() -> method.invoke(p2pOptions)) + .hasCauseInstanceOf(InvalidConfigurationException.class); + } catch (Exception e) { + fail("Test setup failed: " + e.getMessage(), e); + } + } } diff --git a/teku/src/test/resources/P2POptions_config.yaml b/teku/src/test/resources/P2POptions_config.yaml index 44d027d77a4..f9e884ec1ca 100644 --- a/teku/src/test/resources/P2POptions_config.yaml +++ b/teku/src/test/resources/P2POptions_config.yaml @@ -18,4 +18,4 @@ Xp2p-historical-sync-batch-size: 102 Xp2p-sync-batch-size: 103 Xp2p-sync-max-pending-batches: 8 Xp2p-sync-blocks-rate-limit: 100 -Xp2p-sync-blob-sidecars-rate-limit: 400 \ No newline at end of file +Xp2p-sync-blob-sidecars-rate-limit: 400