From 183061b4e97c66ad4de46396fbc2de51f460b1b8 Mon Sep 17 00:00:00 2001 From: Dr Ian Preston Date: Fri, 11 Apr 2025 11:26:03 +0100 Subject: [PATCH 01/30] Quic secure transport implementation (#407) Quic secure transport implementation --------- Co-authored-by: Anton Nashatyrev --- .github/workflows/build.yml | 6 +- install-run-ipfs.sh | 13 + libp2p/build.gradle.kts | 14 +- .../java/io/libp2p/core/dsl/HostBuilder.java | 21 +- .../kotlin/io/libp2p/core/dsl/BuilderJ.kt | 1 + .../kotlin/io/libp2p/core/dsl/Builders.kt | 18 +- .../io/libp2p/core/multiformats/Protocol.kt | 2 + .../libp2p/security/tls/TLSSecureChannel.kt | 123 ++++-- .../implementation/ConnectionOverNetty.kt | 22 +- .../io/libp2p/transport/quic/QuicTransport.kt | 399 ++++++++++++++++++ .../java/io/libp2p/core/HostTestJava.java | 12 +- .../transport/quic/QuicKuboTestJava.java | 84 ++++ .../transport/quic/QuicServerTestJava.java | 323 ++++++++++++++ .../libp2p/core/multiformats/MultiaddrTest.kt | 2 + .../libp2p/security/tls/CertificatesTest.kt | 16 + .../kotlin/io/libp2p/tools/HostFactory.kt | 3 +- versions.gradle | 3 +- 17 files changed, 998 insertions(+), 64 deletions(-) create mode 100755 install-run-ipfs.sh create mode 100644 libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt create mode 100644 libp2p/src/test/java/io/libp2p/transport/quic/QuicKuboTestJava.java create mode 100644 libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4991a5968..31f6920e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,12 +12,16 @@ jobs: with: distribution: temurin java-version: 11 + - name: Install and run ipfs + run: ./install-run-ipfs.sh - name: Setup Gradle uses: gradle/gradle-build-action@v2 - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@v3 + with: + cmdline-tools-version: 8512546 - name: Execute Gradle build run: ./gradlew -s build dokkaJar \ No newline at end of file diff --git a/install-run-ipfs.sh b/install-run-ipfs.sh new file mode 100755 index 000000000..ef35062e7 --- /dev/null +++ b/install-run-ipfs.sh @@ -0,0 +1,13 @@ +#! /bin/sh +wget https://dist.ipfs.io/kubo/v0.34.1/kubo_v0.34.1_linux-amd64.tar.gz -O /tmp/kubo_linux-amd64.tar.gz +hash="$(sha256sum /tmp/kubo_linux-amd64.tar.gz)" +expected=42045802fe60c64fb01350bc071190c534d600fe269759c06e27e22b2012fd3e +if [[ "$hash" != "$expected" ]] +then + echo "incorrect ipfs hash!" 1>&2 + exit 64 +fi +tar -xvf /tmp/kubo_linux-amd64.tar.gz +export PATH=$PATH:$PWD/kubo/ +ipfs init +ipfs daemon --routing=dhtserver & diff --git a/libp2p/build.gradle.kts b/libp2p/build.gradle.kts index 4810f6745..801930267 100644 --- a/libp2p/build.gradle.kts +++ b/libp2p/build.gradle.kts @@ -14,6 +14,8 @@ dependencies { api("io.netty:netty-transport") implementation("io.netty:netty-handler") implementation("io.netty:netty-codec-http") + implementation("io.netty:netty-transport-classes-epoll") + implementation("io.netty.incubator:netty-incubator-codec-native-quic") api("com.google.protobuf:protobuf-java") @@ -22,10 +24,20 @@ dependencies { implementation("org.bouncycastle:bcprov-jdk18on") implementation("org.bouncycastle:bcpkix-jdk18on") - implementation("org.bouncycastle:bctls-jdk18on") testImplementation(project(":tools:schedulers")) + testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::linux-x86_64") + testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::linux-aarch_64") + testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::osx-x86_64") + testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::osx-aarch_64") + testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::windows-x86_64") + testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:linux-x86_64") + testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:linux-aarch_64") + testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:osx-x86_64") + testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:osx-aarch_64") + testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:windows-x86_64") + testFixturesApi("org.apache.logging.log4j:log4j-core") testFixturesImplementation(project(":tools:schedulers")) testFixturesImplementation("io.netty:netty-transport-classes-epoll") diff --git a/libp2p/src/main/java/io/libp2p/core/dsl/HostBuilder.java b/libp2p/src/main/java/io/libp2p/core/dsl/HostBuilder.java index 6eba6a226..a9223f737 100644 --- a/libp2p/src/main/java/io/libp2p/core/dsl/HostBuilder.java +++ b/libp2p/src/main/java/io/libp2p/core/dsl/HostBuilder.java @@ -1,7 +1,7 @@ package io.libp2p.core.dsl; import io.libp2p.core.Host; -import io.libp2p.core.crypto.PrivKey; +import io.libp2p.core.crypto.*; import io.libp2p.core.multistream.ProtocolBinding; import io.libp2p.core.mux.*; import io.libp2p.core.security.SecureChannel; @@ -57,11 +57,23 @@ public final HostBuilder protocol(ProtocolBinding... protocols) { return this; } + @SafeVarargs + public final HostBuilder secureTransport( + BiFunction>, Transport>... transports) { + secureTransports_.addAll(Arrays.asList(transports)); + return this; + } + public final HostBuilder listen(String... addresses) { listenAddresses_.addAll(Arrays.asList(addresses)); return this; } + public HostBuilder keyType(KeyType keyType) { + this.keyType = keyType; + return this; + } + public final HostBuilder builderModifier(Consumer builderModifier) { this.builderModifier = builderModifier; return this; @@ -72,8 +84,9 @@ public Host build() { return BuilderJKt.hostJ( defaultMode_.asBuilderDefault(), b -> { - b.getIdentity().random(); + b.getIdentity().random(keyType); + secureTransports_.forEach(st -> b.getSecureTransports().add(st::apply)); transports_.forEach(t -> b.getTransports().add(t::apply)); secureChannels_.forEach( sc -> b.getSecureChannels().add((k, m) -> sc.apply(k, (List) m))); @@ -85,6 +98,10 @@ public Host build() { } // build private DefaultMode defaultMode_; + private KeyType keyType = KeyType.ECDSA; + private List>, Transport>> + secureTransports_ = new ArrayList<>(); + private List> transports_ = new ArrayList<>(); private List, SecureChannel>> secureChannels_ = new ArrayList<>(); diff --git a/libp2p/src/main/kotlin/io/libp2p/core/dsl/BuilderJ.kt b/libp2p/src/main/kotlin/io/libp2p/core/dsl/BuilderJ.kt index 013bb7201..108f7ee3d 100644 --- a/libp2p/src/main/kotlin/io/libp2p/core/dsl/BuilderJ.kt +++ b/libp2p/src/main/kotlin/io/libp2p/core/dsl/BuilderJ.kt @@ -19,6 +19,7 @@ class BuilderJ : Builder() { public override val identity = super.identity public override val secureChannels = super.secureChannels public override val muxers = super.muxers + public override val secureTransports = super.secureTransports public override val transports = super.transports public override val addressBook = super.addressBook public override val protocols = super.protocols diff --git a/libp2p/src/main/kotlin/io/libp2p/core/dsl/Builders.kt b/libp2p/src/main/kotlin/io/libp2p/core/dsl/Builders.kt index ce1416dfd..36da96b72 100644 --- a/libp2p/src/main/kotlin/io/libp2p/core/dsl/Builders.kt +++ b/libp2p/src/main/kotlin/io/libp2p/core/dsl/Builders.kt @@ -37,6 +37,7 @@ import io.netty.handler.logging.LoggingHandler import java.util.concurrent.CopyOnWriteArrayList typealias TransportCtor = (ConnectionUpgrader) -> Transport +typealias SecureTransportCtor = (PrivKey, List>) -> Transport typealias SecureChannelCtor = (PrivKey, List) -> SecureChannel typealias IdentityFactory = () -> PrivKey @@ -58,6 +59,7 @@ open class Builder { protected open val secureChannels = SecureChannelsBuilder() protected open val muxers = MuxersBuilder() protected open val transports = TransportsBuilder() + protected open val secureTransports = SecureTransportsBuilder() protected open val addressBook = AddressBookBuilder() protected open val protocols = ProtocolsBuilder() protected open val connectionHandlers = ConnectionHandlerBuilder() @@ -126,9 +128,9 @@ open class Builder { if (def == Defaults.None) { if (identity.factory == null) throw IllegalStateException("No identity builder") - if (transports.values.isEmpty()) throw HostConfigurationException("at least one transport is required") - if (secureChannels.values.isEmpty()) throw HostConfigurationException("at least one secure channel is required") - if (muxers.values.isEmpty()) throw HostConfigurationException("at least one muxer is required") + if (secureTransports.isEmpty() && transports.values.isEmpty()) throw HostConfigurationException("at least one transport is required") + if (secureTransports.isEmpty() && secureChannels.values.isEmpty()) throw HostConfigurationException("at least one secure channel or secure transport is required") + if (secureTransports.isEmpty() && muxers.values.isEmpty()) throw HostConfigurationException("at least one muxer or secure transport is required") } if (def == Defaults.Standard) { if (identity.factory == null) identity.random() @@ -189,7 +191,12 @@ open class Builder { val upgrader = ConnectionUpgrader(secureMultistreamProtocol, secureChannels, muxerMultistreamProtocol, muxers) - val transports = transports.values.map { it(upgrader) } + val allTransports = + listOf( + transports.values.map { it(upgrader) }, + secureTransports.values.map { it(privKey, updatableProtocols) } + ).flatten() + val addressBook = addressBook.impl val connHandlerProtocols = protocols.values.mapNotNull { it as? ConnectionHandler } @@ -197,7 +204,7 @@ open class Builder { connHandlerProtocols + connectionHandlers.values ) - val networkImpl = NetworkImpl(transports, broadcastConnHandler) + val networkImpl = NetworkImpl(allTransports, broadcastConnHandler) return HostImpl( privKey, @@ -230,6 +237,7 @@ class AddressBookBuilder { fun memory(): AddressBookBuilder = apply { impl = MemoryAddressBook() } } +class SecureTransportsBuilder : Enumeration() class TransportsBuilder : Enumeration() class SecureChannelsBuilder : Enumeration() class MuxersBuilder : Enumeration() diff --git a/libp2p/src/main/kotlin/io/libp2p/core/multiformats/Protocol.kt b/libp2p/src/main/kotlin/io/libp2p/core/multiformats/Protocol.kt index 5d171b811..dcba6b811 100644 --- a/libp2p/src/main/kotlin/io/libp2p/core/multiformats/Protocol.kt +++ b/libp2p/src/main/kotlin/io/libp2p/core/multiformats/Protocol.kt @@ -42,6 +42,8 @@ enum class Protocol( DNS6(55, LENGTH_PREFIXED_VAR_SIZE, "dns6", UTF8_PARSER, UTF8_STRINGIFIER, UTF8_VALIDATOR), DNSADDR(56, LENGTH_PREFIXED_VAR_SIZE, "dnsaddr", UTF8_PARSER, UTF8_STRINGIFIER, UTF8_VALIDATOR), SCTP(132, 16, "sctp", UINT16_PARSER, UINT16_STRINGIFIER), + WEBRTC_DIRECT(280, 0, "webrtc-direct"), + WEBRTC(28, 0, "webrtc"), UTP(301, 0, "utp"), UDT(302, 0, "udt"), UNIX(400, LENGTH_PREFIXED_VAR_SIZE, "unix", UNIX_PATH_PARSER, UTF8_STRINGIFIER, UTF8_VALIDATOR, isPath = true), diff --git a/libp2p/src/main/kotlin/io/libp2p/security/tls/TLSSecureChannel.kt b/libp2p/src/main/kotlin/io/libp2p/security/tls/TLSSecureChannel.kt index f555418c7..acc6e4687 100644 --- a/libp2p/src/main/kotlin/io/libp2p/security/tls/TLSSecureChannel.kt +++ b/libp2p/src/main/kotlin/io/libp2p/security/tls/TLSSecureChannel.kt @@ -2,6 +2,7 @@ package io.libp2p.security.tls import crypto.pb.Crypto import io.libp2p.core.* +import io.libp2p.core.crypto.KeyType import io.libp2p.core.crypto.PrivKey import io.libp2p.core.crypto.PubKey import io.libp2p.core.crypto.unmarshalPublicKey @@ -25,6 +26,7 @@ import io.netty.handler.ssl.ApplicationProtocolConfig import io.netty.handler.ssl.ClientAuth import io.netty.handler.ssl.SslContextBuilder import io.netty.handler.ssl.SslHandler +import io.netty.handler.ssl.SslProvider import org.bouncycastle.asn1.* import org.bouncycastle.asn1.edec.EdECObjectIdentifiers import org.bouncycastle.asn1.pkcs.PrivateKeyInfo @@ -36,13 +38,15 @@ import org.bouncycastle.cert.X509v3CertificateBuilder import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters import org.bouncycastle.jcajce.interfaces.EdDSAPublicKey -import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +import org.bouncycastle.operator.ContentVerifierProvider +import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder +import org.bouncycastle.operator.bc.BcECContentVerifierProviderBuilder +import org.bouncycastle.operator.bc.BcEdDSAContentVerifierProviderBuilder import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import java.math.BigInteger import java.security.KeyFactory import java.security.PrivateKey -import java.security.PublicKey -import java.security.Security +import java.security.SecureRandom import java.security.cert.Certificate import java.security.cert.CertificateException import java.security.cert.X509Certificate @@ -64,16 +68,10 @@ val certificatePrefix = "libp2p-tls-handshake:".encodeToByteArray() class TlsSecureChannel(private val localKey: PrivKey, private val muxers: List, private val certAlgorithm: String) : SecureChannel { - constructor(localKey: PrivKey, muxerIds: List) : this(localKey, muxerIds, "Ed25519") {} + constructor(localKey: PrivKey, muxerIds: List) : this(localKey, muxerIds, "ECDSA") {} companion object { const val announce = "/tls/1.0.0" - init { - Security.insertProviderAt(Libp2pCrypto.provider, 1) - Security.insertProviderAt(BouncyCastleJsseProvider(), 2) - Security.setProperty("ssl.KeyManagerFactory.algorithm", "PKIX") - Security.setProperty("ssl.TrustManagerFactory.algorithm", "PKIX") - } @JvmStatic fun ECDSA(localKey: PrivKey, muxerIds: List): TlsSecureChannel { @@ -102,35 +100,37 @@ fun buildTlsHandler( expectedRemotePeer: Optional, muxers: List, certAlgorithm: String, - ch: P2PChannel, + isInitiator: Boolean, handshakeComplete: CompletableFuture, ctx: ChannelHandlerContext ): SslHandler { val connectionKeys = if (certAlgorithm.equals("ECDSA")) generateEcdsaKeyPair() else generateEd25519KeyPair() val javaPrivateKey = getJavaKey(connectionKeys.first) val sslContext = ( - if (ch.isInitiator) { - SslContextBuilder.forClient().keyManager(javaPrivateKey, listOf(buildCert(localKey, connectionKeys.first))) + if (isInitiator) { + SslContextBuilder.forClient() + .keyManager(javaPrivateKey, listOf(buildCert(localKey, connectionKeys.first))) } else { SslContextBuilder.forServer(javaPrivateKey, listOf(buildCert(localKey, connectionKeys.first))) + .keyManager(javaPrivateKey, listOf(buildCert(localKey, connectionKeys.first))) } ) .protocols(listOf("TLSv1.3")) .ciphers(listOf("TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256")) .clientAuth(ClientAuth.REQUIRE) .trustManager(Libp2pTrustManager(expectedRemotePeer)) - .sslContextProvider(BouncyCastleJsseProvider()) + .sslProvider(SslProvider.OPENSSL) + .secureRandom(SecureRandom()) .applicationProtocolConfig( ApplicationProtocolConfig( ApplicationProtocolConfig.Protocol.ALPN, - ApplicationProtocolConfig.SelectorFailureBehavior.FATAL_ALERT, - ApplicationProtocolConfig.SelectedListenerFailureBehavior.FATAL_ALERT, + ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE, + ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT, muxers.allProtocols + NoEarlyMuxerNegotiationEntry // early muxer negotiation ) ) .build() val handler = sslContext.newHandler(ctx.alloc()) - handler.sslCloseFuture().addListener { _ -> ctx.close() } val handshake = handler.handshakeFuture() val engine = handler.engine() handshake.addListener { fut -> @@ -180,17 +180,17 @@ private class ChannelSetup( if (!activated) { activated = true val expectedRemotePeerId = ctx.channel().attr(REMOTE_PEER_ID).get() - ctx.channel().pipeline().addLast( - buildTlsHandler( - localKey, - Optional.ofNullable(expectedRemotePeerId), - muxers, - certAlgorithm, - ch, - handshakeComplete, - ctx - ) + val handler = buildTlsHandler( + localKey, + Optional.ofNullable(expectedRemotePeerId), + muxers, + certAlgorithm, + ch.isInitiator, + handshakeComplete, + ctx ) + ctx.channel().pipeline().addLast(handler) + handler.sslCloseFuture().addListener { _ -> ctx.close() } ctx.channel().pipeline().remove(SetupHandlerName) } } @@ -215,18 +215,25 @@ private class ChannelSetup( } class Libp2pTrustManager(private val expectedRemotePeer: Optional) : X509TrustManager { + var remoteCert: Certificate? + + init { + remoteCert = null + } override fun checkClientTrusted(certs: Array?, authType: String?) { if (certs?.size != 1) { throw CertificateException() } - val claimedPeerId = verifyAndExtractPeerId(arrayOf(certs.get(0))) + val cert = certs.get(0) + remoteCert = cert + val claimedPeerId = verifyAndExtractPeerId(arrayOf(cert)) if (expectedRemotePeer.map { ex -> !ex.equals(claimedPeerId) }.orElse(false)) { throw InvalidRemotePubKey() } } override fun checkServerTrusted(certs: Array?, authType: String?) { - return checkClientTrusted(certs, authType) + checkClientTrusted(certs, authType) } override fun getAcceptedIssuers(): Array { @@ -264,18 +271,11 @@ fun getAsn1EncodedPublicKey(pub: PubKey): ByteArray { throw IllegalArgumentException("Unsupported TLS key type:" + pub.keyType) } -fun getPubKey(pub: PublicKey): PubKey { - if (pub.algorithm.equals("EdDSA") || pub.algorithm.equals("Ed25519")) { - val raw = (pub as EdDSAPublicKey).pointEncoding - return Ed25519PublicKey(Ed25519PublicKeyParameters(raw)) - } - if (pub.algorithm.equals("EC")) { - return EcdsaPublicKey(pub as ECPublicKey) +fun getContentVerifier(bcX509Cert: X509CertificateHolder): ContentVerifierProvider { + if (bcX509Cert.signatureAlgorithm.equals(AlgorithmIdentifier(ASN1ObjectIdentifier("1.3.101.112")))) { + return BcEdDSAContentVerifierProviderBuilder().build(bcX509Cert) } - if (pub.algorithm.equals("RSA")) { - throw IllegalStateException("Unimplemented RSA public key support for TLS") - } - throw IllegalStateException("Unsupported key type: " + pub.algorithm) + return BcECContentVerifierProviderBuilder(DefaultDigestAlgorithmIdentifierFinder()).build(bcX509Cert) } fun verifyAndExtractPeerId(chain: Array): PeerId { @@ -298,11 +298,15 @@ fun verifyAndExtractPeerId(chain: Array): PeerId { val pubKeyProto = (seq.getObjectAt(0) as DEROctetString).octets val signature = (seq.getObjectAt(1) as DEROctetString).octets val pubKey = unmarshalPublicKey(pubKeyProto) - if (!pubKey.verify(certificatePrefix.plus(cert.publicKey.encoded), signature)) { + + val pubKeyAsn1 = bcCert.subjectPublicKeyInfo.encoded + if (!pubKey.verify(certificatePrefix.plus(pubKeyAsn1), signature)) { throw IllegalStateException("Invalid signature on TLS certificate extension!") } - cert.verify(cert.publicKey) + if (!bcX509Cert.isSignatureValid(getContentVerifier(bcX509Cert))) { + throw IllegalStateException("TLS certificate has invalid signature!") + } val now = Date() if (bcCert.endDate.date.before(now)) { throw IllegalStateException("TLS certificate has expired!") @@ -313,12 +317,45 @@ fun verifyAndExtractPeerId(chain: Array): PeerId { return PeerId.fromPubKey(pubKey) } +fun getAlgorithmName(oid: String): String { + if ("1.2.840.113549.1.1.1".equals(oid)) { + return "RSA" + } + if ("1.2.840.10045.2.1".equals(oid)) { + return "EC" + } + if ("1.2.840.10040.4.1".equals(oid)) { + return "DSA" + } + return oid +} + +fun getLibp2pKeyFromCert(publicKeyInfo: SubjectPublicKeyInfo): PubKey { + val spec = X509EncodedKeySpec(publicKeyInfo.encoded) + val algorithmName = getAlgorithmName(publicKeyInfo.getAlgorithm().getAlgorithm().getId()) + val pub = KeyFactory.getInstance(algorithmName, Libp2pCrypto.provider).generatePublic(spec) + if (pub.algorithm.equals("EdDSA") || pub.algorithm.equals("Ed25519")) { + val raw = (pub as EdDSAPublicKey).pointEncoding + return Ed25519PublicKey(Ed25519PublicKeyParameters(raw)) + } + if (pub.algorithm.equals("EC")) { + return EcdsaPublicKey(pub as ECPublicKey) + } + if (pub.algorithm.equals("RSA")) { + throw IllegalStateException("Unimplemented RSA public key support for TLS") + } + throw IllegalStateException("Unsupported key type: " + pub.algorithm) +} + fun getPublicKeyFromCert(chain: Array): PubKey { if (chain.size != 1) { throw java.lang.IllegalStateException("Cert chain must have exactly 1 element!") } val cert = chain.get(0) - return getPubKey(cert.publicKey) + val bcCert = org.bouncycastle.asn1.x509.Certificate + .getInstance(ASN1Primitive.fromByteArray(cert.getEncoded())) + + return getLibp2pKeyFromCert(bcCert.subjectPublicKeyInfo) } /** Build a self signed cert, with an extension containing the host key + sig(cert public key) diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionOverNetty.kt b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionOverNetty.kt index 90c1d824f..08b50a994 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionOverNetty.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionOverNetty.kt @@ -8,7 +8,9 @@ import io.libp2p.core.mux.StreamMuxer import io.libp2p.core.security.SecureChannel import io.libp2p.core.transport.Transport import io.libp2p.etc.CONNECTION +import io.libp2p.transport.quic.QuicTransport import io.netty.channel.Channel +import io.netty.incubator.codec.quic.QuicChannel import java.net.Inet4Address import java.net.Inet6Address import java.net.InetSocketAddress @@ -41,14 +43,26 @@ open class ConnectionOverNetty( override fun secureSession() = secureSession override fun transport() = transport - override fun localAddress(): Multiaddr = - toMultiaddr(nettyChannel.localAddress() as InetSocketAddress) - override fun remoteAddress(): Multiaddr = - toMultiaddr(nettyChannel.remoteAddress() as InetSocketAddress) + override fun localAddress(): Multiaddr { + if (nettyChannel is QuicChannel) { + return toMultiaddr(nettyChannel.localSocketAddress() as InetSocketAddress) + } else { + return toMultiaddr(nettyChannel.localAddress() as InetSocketAddress) + } + } + override fun remoteAddress(): Multiaddr { + if (nettyChannel is QuicChannel) { + return toMultiaddr(nettyChannel.remoteSocketAddress() as InetSocketAddress) + } else { + return toMultiaddr(nettyChannel.remoteAddress() as InetSocketAddress) + } + } private fun toMultiaddr(addr: InetSocketAddress): Multiaddr { if (transport is NettyTransport) { return transport.toMultiaddr(addr) + } else if (transport is QuicTransport) { + return transport.toMultiaddr(addr) } else { return toMultiaddrDefault(addr) } diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt new file mode 100644 index 000000000..72d7fac4e --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt @@ -0,0 +1,399 @@ +package io.libp2p.transport.quic + +import io.libp2p.core.* +import io.libp2p.core.crypto.PrivKey +import io.libp2p.core.crypto.unmarshalPublicKey +import io.libp2p.core.multiformats.Multiaddr +import io.libp2p.core.multiformats.MultiaddrDns +import io.libp2p.core.multiformats.Multihash +import io.libp2p.core.multiformats.Protocol.* +import io.libp2p.core.multistream.MultistreamProtocol +import io.libp2p.core.multistream.MultistreamProtocolV1 +import io.libp2p.core.multistream.ProtocolBinding +import io.libp2p.core.mux.StreamMuxer +import io.libp2p.core.security.SecureChannel +import io.libp2p.core.transport.Transport +import io.libp2p.crypto.keys.generateEcdsaKeyPair +import io.libp2p.crypto.keys.generateEd25519KeyPair +import io.libp2p.etc.CONNECTION +import io.libp2p.etc.STREAM +import io.libp2p.etc.types.* +import io.libp2p.etc.util.netty.nettyInitializer +import io.libp2p.security.tls.* +import io.libp2p.transport.implementation.ConnectionOverNetty +import io.libp2p.transport.implementation.StreamOverNetty +import io.netty.bootstrap.Bootstrap +import io.netty.buffer.ByteBuf +import io.netty.buffer.PooledByteBufAllocator +import io.netty.channel.* +import io.netty.channel.epoll.Epoll +import io.netty.channel.epoll.EpollDatagramChannel +import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.socket.nio.NioDatagramChannel +import io.netty.handler.ssl.ClientAuth +import io.netty.incubator.codec.quic.* +import java.net.* +import java.time.Duration +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +class QuicTransport( + private val localKey: PrivKey, + private val certAlgorithm: String, + private val protocols: List> +) : Transport { + + private var closed = false + var connectTimeout = Duration.ofSeconds(15) + + private val listeners = mutableMapOf() + private val channels = mutableListOf() + + private var workerGroup by lazyVar { NioEventLoopGroup() } + private var bossGroup by lazyVar { NioEventLoopGroup(1) } + private var allocator by lazyVar { PooledByteBufAllocator(true) } + private var multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 + private var incomingMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } + + private var client by lazyVar { + Bootstrap().group(workerGroup) + .channel( + if (Epoll.isAvailable()) { + EpollDatagramChannel::class.java + } else { + NioDatagramChannel::class.java + } + ) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout.toMillis().toInt()) + } + + companion object { + @JvmStatic + fun Ed25519(k: PrivKey, p: List>): QuicTransport { + return QuicTransport(k, "Ed25519", p) + } + + @JvmStatic + fun Ecdsa(k: PrivKey, p: List>): QuicTransport { + return QuicTransport(k, "ECDSA", p) + } + + private fun createStream(channel: Channel, connection: Connection, initiator: Boolean): Stream { + val stream = StreamOverNetty(channel, connection, initiator) + channel.attr(STREAM).set(stream) + return stream + } + } + + private var server by lazyVar { + Bootstrap().group(workerGroup) + .channel( + if (Epoll.isAvailable()) { + EpollDatagramChannel::class.java + } else { + NioDatagramChannel::class.java + } + ) + } + + override val activeListeners: Int + get() = listeners.size + override val activeConnections: Int + get() = channels.size + + override fun listenAddresses(): List { + return listeners.values.map { + toMultiaddr(it.localAddress() as InetSocketAddress) + } + } + + override fun initialize() { + } + + override fun close(): CompletableFuture { + closed = true + + val unbindsCompleted = listeners + .map { (_, ch) -> ch } + .map { it.close().toVoidCompletableFuture() } + + val channelsClosed = channels + .toMutableList() // need a copy to avoid potential co-modification problems + .map { it.close().toVoidCompletableFuture() } + + val everythingThatNeedsToClose = unbindsCompleted.union(channelsClosed) + val allClosed = CompletableFuture.allOf(*everythingThatNeedsToClose.toTypedArray()) + + return allClosed.thenApply { + workerGroup.shutdownGracefully() + bossGroup.shutdownGracefully() + Unit + } + } + + override fun listen( + addr: Multiaddr, + connHandler: ConnectionHandler, + preHandler: ChannelVisitor? + ): CompletableFuture { + if (closed) throw Libp2pException("Transport is closed") + + val channelHandler = serverTransportBuilder(connHandler, preHandler) + + val bindComplete = server.clone() + .handler( + nettyInitializer { init -> + registerChannel(init.channel) + init.addLastLocal(channelHandler) + } + ) + .localAddress(fromMultiaddr(addr)) + .bind() + .sync() + + val res = CompletableFuture() + bindComplete.also { + synchronized(this@QuicTransport) { + listeners += addr to it.channel() + it.channel().closeFuture().addListener { + synchronized(this@QuicTransport) { + listeners -= addr + } + } + println("Quic server listening on " + addr) + res.complete(null) + } + } + + return res + } + + override fun unlisten(addr: Multiaddr): CompletableFuture { + return listeners[addr]?.close()?.toVoidCompletableFuture() + ?: throw Libp2pException("No listeners on address $addr") + } + + override fun dial(addr: Multiaddr, connHandler: ConnectionHandler, preHandler: ChannelVisitor?): CompletableFuture { + if (closed) throw Libp2pException("Transport is closed") + + val trust = Libp2pTrustManager(Optional.ofNullable(addr.getPeerId())) + val sslContext = quicSslContext(addr.getPeerId(), trust) + val handler = QuicClientCodecBuilder() + .sslEngineProvider({ q -> sslContext.newEngine(q.alloc()) }) + .maxIdleTimeout(15000, TimeUnit.MILLISECONDS) + .sslTaskExecutor(workerGroup) + .initialMaxData(1024) + .initialMaxStreamsBidirectional(16) + .initialMaxStreamDataBidirectionalRemote(1024) + .initialMaxStreamDataBidirectionalLocal(1024) + .build() + + val connFuture = QuicChannel.newBootstrap( + client.clone() + .handler(handler) + .localAddress(0) + .bind() + .sync() + .channel() + ) + .streamOption(ChannelOption.ALLOCATOR, allocator) + .option(ChannelOption.AUTO_READ, true) + .option(ChannelOption.ALLOCATOR, allocator) + .remoteAddress(fromMultiaddr(addr)) +// .handler(connHandler) + .streamHandler(object : ChannelInboundHandlerAdapter() { + override fun handlerAdded(ctx: ChannelHandlerContext?) { + val connection = ctx!!.channel().parent().attr(CONNECTION).get() as Connection + preHandler?.also { it.visit(connection) } + connHandler.handleConnection(connection) + } + }) + .connect() + + val res = CompletableFuture() + connFuture.also { + registerChannel(it.get()) + val connection = ConnectionOverNetty(it.get(), this, true) + connection.setMuxerSession(object : StreamMuxer.Session { + override fun createStream(protocols: List>): StreamPromise { + var multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 + var streamMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } + val multi = streamMultistreamProtocol.createMultistream(protocols) + + val controller = CompletableFuture() + val streamFut = CompletableFuture() + it.get().createStream( + QuicStreamType.BIDIRECTIONAL, + object : ChannelInboundHandlerAdapter() { + override fun handlerAdded(ctx: ChannelHandlerContext?) { + val stream = createStream(ctx!!.channel(), connection, true) + ctx.channel().attr(STREAM).set(stream) + val streamHandler = multi.toStreamHandler() + streamHandler.handleStream(stream).forward(controller).apply { streamFut.complete(stream) } + } + } + ) + return StreamPromise(streamFut, controller) + } + }) + val pubHash = Multihash.of(addr.getPeerId()!!.bytes.toByteBuf()) + val remotePubKey = if (pubHash.desc.digest == Multihash.Digest.Identity) { + unmarshalPublicKey(pubHash.bytes.toByteArray()) + } else { + getPublicKeyFromCert(arrayOf(trust.remoteCert!!)) + } + connection.setSecureSession( + SecureChannel.Session( + PeerId.fromPubKey(localKey.publicKey()), + addr.getPeerId()!!, + remotePubKey, + null + ) + ) + preHandler?.also { it.visit(connection) } + connHandler.handleConnection(connection) + res.complete(connection) + } + return res + } + + private fun registerChannel(ch: Channel) { + if (closed) { + ch.close() + return + } + + synchronized(this@QuicTransport) { + channels += ch + ch.closeFuture().addListener { + synchronized(this@QuicTransport) { + channels -= ch + } + } + } + } + + protected fun handlesHost(addr: Multiaddr) = + addr.hasAny(IP4, IP6, DNS4, DNS6, DNSADDR) + + protected fun hostFromMultiaddr(addr: Multiaddr): String { + val resolvedAddresses = MultiaddrDns.resolve(addr) + if (resolvedAddresses.isEmpty()) { + throw Libp2pException("Could not resolve $addr to an IP address") + } + + return resolvedAddresses[0].components.find { + it.protocol in arrayOf(IP4, IP6) + }?.stringValue ?: throw Libp2pException("Missing IP4/IP6 in multiaddress $addr") + } + override fun handles(addr: Multiaddr) = + handlesHost(addr) && + addr.has(UDP) && + addr.has(QUICV1) && + !addr.has(WS) + + fun quicSslContext(expectedRemotePeerId: PeerId?, trustManager: Libp2pTrustManager): QuicSslContext { + val connectionKeys = if (certAlgorithm.equals("ECDSA")) generateEcdsaKeyPair() else generateEd25519KeyPair() + val javaPrivateKey = getJavaKey(connectionKeys.first) + val isClient = expectedRemotePeerId != null + val cert = buildCert(localKey, connectionKeys.first) + println("Building " + certAlgorithm + " keys and cert for peerid " + PeerId.fromPubKey(localKey.publicKey())) + return ( + if (isClient) { + QuicSslContextBuilder.forClient().keyManager(javaPrivateKey, null, cert) + } else { + QuicSslContextBuilder.forServer(javaPrivateKey, null, cert).clientAuth(ClientAuth.REQUIRE) + } + ) +// .option(BoringSSLContextOption.GROUPS, arrayOf("x25519")) +// .option( +// BoringSSLContextOption.SIGNATURE_ALGORITHMS, +// arrayOf( +// // "ed25519", +// "ecdsa_secp256r1_sha256", +// "rsa_pkcs1_sha256", +// "rsa_pss_rsae_sha256", +// "ecdsa_secp384r1_sha384", +// "rsa_pkcs1_sha384", +// "rsa_pss_rsae_sha384", +// "rsa_pss_rsae_sha512", +// "rsa_pkcs1_sha512", +// ) +// ) + .trustManager(trustManager) + .applicationProtocols("libp2p") + .build() + } + + fun serverTransportBuilder(connHandler: ConnectionHandler, preHandler: ChannelVisitor?): ChannelHandler { + val sslContext = quicSslContext(null, Libp2pTrustManager(Optional.empty())) + return QuicServerCodecBuilder() + .sslEngineProvider({ q -> sslContext.newEngine(q.alloc()) }) + .maxIdleTimeout(5000, TimeUnit.MILLISECONDS) + .sslTaskExecutor(workerGroup) + .tokenHandler(NoTokenHandler()) + .handler(object : ChannelInitializer() { + override fun initChannel(ch: Channel) { + val connection = ConnectionOverNetty(ch, this@QuicTransport, false) + ch.attr(CONNECTION).set(connection) + preHandler?.also { it.visit(connection) } + connHandler.handleConnection(connection) + } + }) + .initialMaxData(1024) + .initialMaxStreamsBidirectional(16) + .initialMaxStreamDataBidirectionalRemote(1024) + .initialMaxStreamDataBidirectionalLocal(1024) + .streamHandler(InboundStreamHandler(incomingMultistreamProtocol, protocols)) + .build() + } + + class InboundStreamHandler( + val handler: MultistreamProtocol, + val protocols: List> + ) : ChannelInitializer() { + override fun initChannel(ch: Channel) { + val connection = ch.parent().attr(CONNECTION).get() + val stream = createStream(ch, connection, false) + val streamHandler = handler.createMultistream(protocols).toStreamHandler() + streamHandler.handleStream(stream) + } + } + + class NoTokenHandler : QuicTokenHandler { + override fun writeToken(out: ByteBuf?, dcid: ByteBuf?, address: InetSocketAddress?): Boolean { + return false + } + + override fun validateToken(token: ByteBuf?, address: InetSocketAddress?): Int { + return -1 + } + + override fun maxTokenLength(): Int { + return 0 + } + } + + fun udpPortFromMultiaddr(addr: Multiaddr) = + addr.components.find { p -> p.protocol == UDP } + ?.stringValue?.toInt() ?: throw Libp2pException("Missing UDP in multiaddress $addr") + + fun fromMultiaddr(addr: Multiaddr): SocketAddress { + val host = hostFromMultiaddr(addr) + val port = udpPortFromMultiaddr(addr) + return InetSocketAddress(host, port) + } + + fun toMultiaddr(addr: InetSocketAddress): Multiaddr { + val proto = when (addr.address) { + is Inet4Address -> IP4 + is Inet6Address -> IP6 + else -> throw InternalErrorException("Unknown address type $addr") + } + return Multiaddr.empty() + .withComponent(proto, addr.address.hostAddress) + .withComponent(UDP, addr.port.toString()) + .withComponent(QUICV1) + } +} diff --git a/libp2p/src/test/java/io/libp2p/core/HostTestJava.java b/libp2p/src/test/java/io/libp2p/core/HostTestJava.java index bd4f509e0..4b3b9fd37 100644 --- a/libp2p/src/test/java/io/libp2p/core/HostTestJava.java +++ b/libp2p/src/test/java/io/libp2p/core/HostTestJava.java @@ -38,14 +38,14 @@ void ping() throws Exception { Host clientHost = new HostBuilder() .transport(TcpTransport::new) - .secureChannel((k, m) -> new TlsSecureChannel(k, m, "ECDSA")) + .secureChannel(TlsSecureChannel::ECDSA) .muxer(StreamMuxerProtocol::getYamux) .build(); Host serverHost = new HostBuilder() .transport(TcpTransport::new) - .secureChannel(TlsSecureChannel::new) + .secureChannel(TlsSecureChannel::ECDSA) .muxer(StreamMuxerProtocol::getYamux) .protocol(new Ping()) .listen(localListenAddress) @@ -100,14 +100,14 @@ void largePing() throws Exception { Host clientHost = new HostBuilder() .transport(TcpTransport::new) - .secureChannel((k, m) -> new TlsSecureChannel(k, m, "ECDSA")) + .secureChannel(TlsSecureChannel::ECDSA) .muxer(StreamMuxerProtocol::getYamux) .build(); Host serverHost = new HostBuilder() .transport(TcpTransport::new) - .secureChannel(TlsSecureChannel::new) + .secureChannel(TlsSecureChannel::ECDSA) .muxer(StreamMuxerProtocol::getYamux) .protocol(new Ping(pingSize)) .listen(localListenAddress) @@ -227,14 +227,14 @@ void addPingAfterHostStart() throws Exception { Host clientHost = new HostBuilder() .transport(TcpTransport::new) - .secureChannel((k, m) -> new TlsSecureChannel(k, m, "ECDSA")) + .secureChannel(TlsSecureChannel::ECDSA) .muxer(StreamMuxerProtocol::getYamux) .build(); Host serverHost = new HostBuilder() .transport(TcpTransport::new) - .secureChannel(TlsSecureChannel::new) + .secureChannel(TlsSecureChannel::ECDSA) .muxer(StreamMuxerProtocol::getYamux) .listen(localListenAddress) .build(); diff --git a/libp2p/src/test/java/io/libp2p/transport/quic/QuicKuboTestJava.java b/libp2p/src/test/java/io/libp2p/transport/quic/QuicKuboTestJava.java new file mode 100644 index 000000000..bb408db73 --- /dev/null +++ b/libp2p/src/test/java/io/libp2p/transport/quic/QuicKuboTestJava.java @@ -0,0 +1,84 @@ +package io.libp2p.transport.quic; + +import io.libp2p.core.Host; +import io.libp2p.core.PeerId; +import io.libp2p.core.Stream; +import io.libp2p.core.StreamPromise; +import io.libp2p.core.crypto.*; +import io.libp2p.core.dsl.*; +import io.libp2p.core.multiformats.*; +import io.libp2p.protocol.*; +import java.io.*; +import java.net.*; +import java.util.concurrent.*; +import kotlin.*; +import org.junit.jupiter.api.*; + +public class QuicKuboTestJava { + @Test + void pingKubo() throws Exception { + if (System.getProperty("os.name").toLowerCase().startsWith("windows")) return; + PeerId peerId = PeerId.fromBase58(getKuboPeerId()); + + Host clientHost = + new HostBuilder().keyType(KeyType.ED25519).secureTransport(QuicTransport::Ecdsa).build(); + + CompletableFuture clientStarted = clientHost.start(); + clientStarted.get(5, TimeUnit.SECONDS); + System.out.println("Client started"); + + Assertions.assertEquals(0, clientHost.listenAddresses().size()); + + StreamPromise ping = + clientHost + .getNetwork() + .connect(peerId, new Multiaddr("/ip4/127.0.0.1/udp/4001/quic-v1")) + .thenApply(it -> it.muxerSession().createStream(new Ping())) + .get(5, TimeUnit.SECONDS); + + Stream pingStream = ping.getStream().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream created"); + PingController pingCtr = ping.getController().get(5, TimeUnit.SECONDS); + System.out.println("Ping controller created"); + + for (int i = 0; i < 1000; i++) { + long latency = pingCtr.ping().get(1, TimeUnit.SECONDS); + System.out.println("Ping is " + latency); + } + pingStream.close().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream closed"); + + Assertions.assertThrows( + ExecutionException.class, () -> pingCtr.ping().get(5, TimeUnit.SECONDS)); + + clientHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Client stopped"); + } + + private static String getKuboPeerId() throws IOException, URISyntaxException { + String url = "http://localhost:5001/api/v0/id"; + HttpURLConnection conn = (HttpURLConnection) new URI(url).toURL().openConnection(); + conn.setConnectTimeout(1_000); + conn.setDoInput(true); + conn.setDoOutput(true); + + DataOutputStream dout = new DataOutputStream(conn.getOutputStream()); + + dout.write(new byte[0]); + dout.flush(); + + DataInputStream din = new DataInputStream(conn.getInputStream()); + String resp = new String(din.readAllBytes()); + din.close(); + int start = resp.indexOf("ID") + 5; + int end = resp.indexOf("\"", start); + return resp.substring(start, end); + } + + @Test + void keyPairGeneration() { + Pair pair = KeyKt.generateKeyPair(KeyType.SECP256K1); + PeerId peerId = PeerId.fromPubKey(pair.component2()); + System.out.println("PeerId: " + peerId.toHex()); + } +} diff --git a/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java b/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java new file mode 100644 index 000000000..0fb95b198 --- /dev/null +++ b/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java @@ -0,0 +1,323 @@ +package io.libp2p.transport.quic; + +import io.libp2p.core.Host; +import io.libp2p.core.PeerId; +import io.libp2p.core.Stream; +import io.libp2p.core.StreamPromise; +import io.libp2p.core.crypto.*; +import io.libp2p.core.dsl.*; +import io.libp2p.core.multiformats.*; +import io.libp2p.core.mux.StreamMuxerProtocol; +import io.libp2p.protocol.*; +import io.libp2p.security.noise.NoiseXXSecureChannel; +import io.libp2p.security.tls.TlsSecureChannel; +import io.libp2p.transport.tcp.TcpTransport; +import io.netty.handler.logging.LogLevel; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.*; +import java.util.stream.Collectors; +import kotlin.*; +import org.junit.jupiter.api.*; + +public class QuicServerTestJava { + public static int getPort() { + return new Random().nextInt(20_000) + 10_000; + } + + @Test + void pingJava() throws Exception { + String localListenAddress = "/ip4/127.0.0.1/udp/" + getPort() + "/quic-v1"; + + Host clientHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::Ecdsa) + .transport(TcpTransport::new) + .secureChannel(TlsSecureChannel::ECDSA) + .muxer(StreamMuxerProtocol::getYamux) + .build(); + + Host serverHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::Ecdsa) + .transport(TcpTransport::new) + .secureChannel(TlsSecureChannel::ECDSA) + .muxer(StreamMuxerProtocol::getYamux) + .protocol(new Ping()) + .listen(localListenAddress) + .build(); + + CompletableFuture clientStarted = clientHost.start(); + CompletableFuture serverStarted = serverHost.start(); + clientStarted.get(5, TimeUnit.SECONDS); + System.out.println("Client started " + clientHost.getPeerId()); + serverStarted.get(5, TimeUnit.SECONDS); + System.out.println("Server started " + serverHost.getPeerId()); + + Assertions.assertEquals(0, clientHost.listenAddresses().size()); + Assertions.assertEquals(1, serverHost.listenAddresses().size()); + Assertions.assertEquals( + localListenAddress + "/p2p/" + serverHost.getPeerId(), + serverHost.listenAddresses().get(0).toString()); + System.out.println("Hosts running"); + Thread.sleep(2_000); + + StreamPromise ping = + clientHost + .getNetwork() + .connect(serverHost.getPeerId(), new Multiaddr(localListenAddress)) + .thenApply(it -> it.muxerSession().createStream(new Ping(500))) + .get(5000, TimeUnit.SECONDS); + + Stream pingStream = ping.getStream().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream created"); + CompletableFuture controller = ping.getController(); + PingController pingCtr = controller.get(5000, TimeUnit.SECONDS); + System.out.println("Ping controller created"); + pingStream.getConnection().localAddress(); + Multiaddr remote = pingStream.getConnection().remoteAddress(); + Assertions.assertEquals(localListenAddress, remote.toString()); + + for (int i = 0; i < 1000; i++) { + long latency = pingCtr.ping().get(1, TimeUnit.SECONDS); + System.out.println("Ping is " + latency); + } + pingStream.close().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream closed"); + + Assertions.assertThrows( + ExecutionException.class, () -> pingCtr.ping().get(5, TimeUnit.SECONDS)); + + clientHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Client stopped"); + serverHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Server stopped"); + } + + @Test + void tlsAndQuicInSameHostPing() throws Exception { + int port = getPort(); + String localQuicListenAddress = "/ip4/127.0.0.1/udp/" + port + "/quic-v1"; + String localTcpListenAddress = "/ip4/127.0.0.1/tcp/" + port; + + Host clientHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::Ecdsa) + .transport(TcpTransport::new) + .secureChannel(TlsSecureChannel::ECDSA) + .secureChannel(NoiseXXSecureChannel::new) + .muxer(StreamMuxerProtocol::getYamux) + .build(); + + Host serverHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::Ecdsa) + .transport(TcpTransport::new) + .secureChannel(TlsSecureChannel::ECDSA) + .secureChannel(NoiseXXSecureChannel::new) + .muxer(StreamMuxerProtocol::getYamux) + .protocol(new Ping()) + .listen(localQuicListenAddress, localTcpListenAddress) + .build(); + + CompletableFuture clientStarted = clientHost.start(); + CompletableFuture serverStarted = serverHost.start(); + clientStarted.get(5, TimeUnit.SECONDS); + System.out.println("Client started " + clientHost.getPeerId()); + serverStarted.get(5, TimeUnit.SECONDS); + System.out.println("Server started " + serverHost.getPeerId()); + + Assertions.assertEquals(0, clientHost.listenAddresses().size()); + Assertions.assertEquals(2, serverHost.listenAddresses().size()); + Assertions.assertEquals( + Set.of( + localTcpListenAddress + "/p2p/" + serverHost.getPeerId(), + localQuicListenAddress + "/p2p/" + serverHost.getPeerId()), + serverHost.listenAddresses().stream().map(Multiaddr::toString).collect(Collectors.toSet())); + System.out.println("Hosts running"); + Thread.sleep(2_000); + + StreamPromise tcpPing = + clientHost + .getNetwork() + .connect(serverHost.getPeerId(), new Multiaddr(localTcpListenAddress)) + .thenApply(it -> it.muxerSession().createStream(new Ping(500))) + .get(5000, TimeUnit.SECONDS); + + Stream pingStream = tcpPing.getStream().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream created"); + CompletableFuture controller = tcpPing.getController(); + PingController pingCtr = controller.get(5000, TimeUnit.SECONDS); + System.out.println("Ping controller created"); + + for (int i = 0; i < 1000; i++) { + long latency = pingCtr.ping().get(1, TimeUnit.SECONDS); + System.out.println("Ping is " + latency); + } + pingStream.close().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream closed"); + + Assertions.assertThrows( + ExecutionException.class, () -> pingCtr.ping().get(5, TimeUnit.SECONDS)); + + StreamPromise quicPing = + clientHost + .getNetwork() + .connect(serverHost.getPeerId(), new Multiaddr(localQuicListenAddress)) + .thenApply(it -> it.muxerSession().createStream(new Ping(500))) + .get(5000, TimeUnit.SECONDS); + + Stream quicPingStream = quicPing.getStream().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream created"); + CompletableFuture quicController = quicPing.getController(); + PingController quicPingCtr = quicController.get(5000, TimeUnit.SECONDS); + System.out.println("Ping controller created"); + + for (int i = 0; i < 1000; i++) { + long latency = quicPingCtr.ping().get(1, TimeUnit.SECONDS); + System.out.println("Ping is " + latency); + } + quicPingStream.close().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream closed"); + + Assertions.assertThrows( + ExecutionException.class, () -> quicPingCtr.ping().get(5, TimeUnit.SECONDS)); + + clientHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Client stopped"); + serverHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Server stopped"); + } + + @Test + void largeBlob() throws Exception { + int blobSize = 1024 * 1024; + String localListenAddress = "/ip4/127.0.0.1/udp/" + getPort() + "/quic-v1"; + + Host clientHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::Ecdsa) + .builderModifier( + b -> b.getDebug().getMuxFramesHandler().addCompactLogger(LogLevel.ERROR, "client")) + .build(); + + Host serverHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::Ecdsa) + .protocol(new Blob(blobSize)) + .listen(localListenAddress) + .builderModifier( + b -> b.getDebug().getMuxFramesHandler().addCompactLogger(LogLevel.ERROR, "server")) + .build(); + + CompletableFuture clientStarted = clientHost.start(); + CompletableFuture serverStarted = serverHost.start(); + clientStarted.get(5, TimeUnit.SECONDS); + System.out.println("Client started"); + serverStarted.get(5, TimeUnit.SECONDS); + System.out.println("Server started"); + + Assertions.assertEquals(0, clientHost.listenAddresses().size()); + Assertions.assertEquals(1, serverHost.listenAddresses().size()); + Assertions.assertEquals( + localListenAddress + "/p2p/" + serverHost.getPeerId(), + serverHost.listenAddresses().get(0).toString()); + + StreamPromise blob = + clientHost + .getNetwork() + .connect(serverHost.getPeerId(), new Multiaddr(localListenAddress)) + .thenApply(it -> it.muxerSession().createStream(new Blob(blobSize))) + .join(); + + Stream blobStream = blob.getStream().get(5, TimeUnit.SECONDS); + System.out.println("Blob stream created"); + BlobController blobCtr = blob.getController().get(5, TimeUnit.SECONDS); + System.out.println("Blob controller created"); + + for (int i = 0; i < 10; i++) { + long latency = blobCtr.blob().join(); + System.out.println("Blob round trip is " + latency); + } + blobStream.close().get(5, TimeUnit.SECONDS); + System.out.println("Blob stream closed"); + + Assertions.assertThrows( + ExecutionException.class, () -> blobCtr.blob().get(5, TimeUnit.SECONDS)); + + clientHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Client stopped"); + serverHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Server stopped"); + } + + @Test + void startHostAddPing() throws Exception { + String localListenAddress = "/ip4/127.0.0.1/udp/" + getPort() + "/quic-v1"; + + Host clientHost = + new HostBuilder().keyType(KeyType.ED25519).secureTransport(QuicTransport::Ecdsa).build(); + + Host serverHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::Ecdsa) + .listen(localListenAddress) + .build(); + + CompletableFuture clientStarted = clientHost.start(); + CompletableFuture serverStarted = serverHost.start(); + clientStarted.get(5, TimeUnit.SECONDS); + System.out.println("Client started"); + serverStarted.get(5, TimeUnit.SECONDS); + System.out.println("Server started"); + + Assertions.assertEquals(0, clientHost.listenAddresses().size()); + Assertions.assertEquals(1, serverHost.listenAddresses().size()); + Assertions.assertEquals( + localListenAddress + "/p2p/" + serverHost.getPeerId(), + serverHost.listenAddresses().get(0).toString()); + + serverHost.addProtocolHandler(new Ping()); + + StreamPromise ping = + clientHost + .getNetwork() + .connect(serverHost.getPeerId(), new Multiaddr(localListenAddress)) + .thenApply(it -> it.muxerSession().createStream(new Ping())) + .get(5, TimeUnit.SECONDS); + + Stream pingStream = ping.getStream().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream created"); + PingController pingCtr = ping.getController().get(5, TimeUnit.SECONDS); + System.out.println("Ping controller created"); + + for (int i = 0; i < 10; i++) { + long latency = pingCtr.ping().get(1, TimeUnit.SECONDS); + System.out.println("Ping is " + latency); + } + pingStream.close().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream closed"); + + Assertions.assertThrows( + ExecutionException.class, () -> pingCtr.ping().get(5, TimeUnit.SECONDS)); + + clientHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Client stopped"); + serverHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Server stopped"); + } + + @Test + void keyPairGeneration() { + Pair pair = KeyKt.generateKeyPair(KeyType.SECP256K1); + PeerId peerId = PeerId.fromPubKey(pair.component2()); + System.out.println("PeerId: " + peerId.toHex()); + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/core/multiformats/MultiaddrTest.kt b/libp2p/src/test/kotlin/io/libp2p/core/multiformats/MultiaddrTest.kt index e1c274aaf..6365114ae 100644 --- a/libp2p/src/test/kotlin/io/libp2p/core/multiformats/MultiaddrTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/core/multiformats/MultiaddrTest.kt @@ -133,6 +133,8 @@ class MultiaddrTest { "/ip4/1.2.3.4/tcp/80/unix/a/b/c/d/e/f", "/ip4/127.0.0.1/ipfs/QmcgpsyWgH8Y8ajJz1Cu72KnS5uo2Aa2LpzU7kinSupNKC/tcp/1234/unix/stdio", "/ip4/127.0.0.1/p2p/QmcgpsyWgH8Y8ajJz1Cu72KnS5uo2Aa2LpzU7kinSupNKC/tcp/1234/unix/stdio", + "/ip4/127.0.0.1/tcp/127/webrtc-direct", + "/ip4/127.0.0.1/tcp/127/webrtc", "/ip4/127.0.0.1/tcp/40001/p2p/16Uiu2HAkuqGKz8D6khfrnJnDrN5VxWWCoLU8Aq4eCFJuyXmfakB5", "/ip6/2001:6b0:30:1000:d00e:1dff:fe0b:c764/udp/4001/quic-v1/webtransport/certhash/uEiAEz_3prFf34VZff8XqA1iTdq2Ytp467ErTGr5dRFo60Q/certhash/uEiDyL7yksuIGJsYUvf0AHieLkTux5R5KBk-UsFtA1AG18A" ) diff --git a/libp2p/src/test/kotlin/io/libp2p/security/tls/CertificatesTest.kt b/libp2p/src/test/kotlin/io/libp2p/security/tls/CertificatesTest.kt index feb2e6a98..940a3afa4 100644 --- a/libp2p/src/test/kotlin/io/libp2p/security/tls/CertificatesTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/security/tls/CertificatesTest.kt @@ -18,6 +18,7 @@ class CertificatesTest { val certBytes = Hex.decode(hex) val certHolder = X509CertificateHolder(certBytes) val cert = JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(certHolder) + getPublicKeyFromCert(arrayOf(cert)) val peerIdFromCert = verifyAndExtractPeerId(arrayOf(cert)) val expectedPeerId = PeerId.fromBase58("12D3KooWJRSrypvnpHgc6ZAgyCni4KcSmbV7uGRaMw5LgMKT18fq") assertEquals(peerIdFromCert, expectedPeerId) @@ -29,6 +30,7 @@ class CertificatesTest { val certBytes = Hex.decode(hex) val certHolder = X509CertificateHolder(certBytes) val cert = JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(certHolder) + getPublicKeyFromCert(arrayOf(cert)) val peerIdFromCert = verifyAndExtractPeerId(arrayOf(cert)) val expectedPeerId = PeerId.fromBase58("QmZcrvr3r4S3QvwFdae3c2EWTfo792Y14UpzCZurhmiWeX") assertEquals(peerIdFromCert, expectedPeerId) @@ -40,11 +42,24 @@ class CertificatesTest { val certBytes = Hex.decode(hex) val certHolder = X509CertificateHolder(certBytes) val cert = JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(certHolder) + getPublicKeyFromCert(arrayOf(cert)) val peerIdFromCert = verifyAndExtractPeerId(arrayOf(cert)) val expectedPeerId = PeerId.fromBase58("16Uiu2HAm2dSCBFxuge46aEt7U1oejtYuBUZXxASHqmcfVmk4gsbx") assertEquals(peerIdFromCert, expectedPeerId) } + @Test + fun rustCert() { + val hex = "3082018230820129a00302010202144d1178a3bb828459ce1e266baa234ed8f0615c06300a06082a8648ce3d04030230003020170d3735303130313030303030305a180f34303936303130313030303030305a30003059301306072a8648ce3d020106082a8648ce3d03010703420004089ff3ab6e4b42cb2252a41aff3b8cb7c6f71f7050f6604ff138219f35652de1f6a006f487cd15d88db31e12dcd3b080cd53aa5869a649a13762b6193029f61ca37f307d307b060a2b0601040183a25a01010101ff046a30680424080112207f249e77411a3fa0c3f6305a8446cd45f9fb73ae2412f230f21943cf15dabc3d044025544b48ff50963b5f26b277906a08ba3f231d2d80f399801f856e21e3d9ec2b84c51f8063eb4ae70e52cd940ff82a5aa29b82f3f82b5fb2ae67a9d5bba75c0b300a06082a8648ce3d0403020347003044022031580479526dd6a38a3cc1e90122ac9437d3633aa63f697165099e3d3c4cb3b70220525a60d13802089a9cbb0752646a2801df74d06d6f7785ff21931dca4e188e16" + val certBytes = Hex.decode(hex) + val certHolder = X509CertificateHolder(certBytes) + val cert = JcaX509CertificateConverter().setProvider(BouncyCastleProvider()).getCertificate(certHolder) + getPublicKeyFromCert(arrayOf(cert)) + val peerIdFromCert = verifyAndExtractPeerId(arrayOf(cert)) + val expectedPeerId = PeerId.fromBase58("12D3KooWJNgLEeuYt54A58gcnsggjHhVt6YBsrK71QRXTzK9WABn") + assertEquals(peerIdFromCert, expectedPeerId) + } + @Test fun invalidCert() { val hex = "308201773082011da003020102020830a73c5d896a1109300a06082a8648ce3d04030230003020170d3735303130313030303030305a180f34303936303130313030303030305a30003059301306072a8648ce3d020106082a8648ce3d03010703420004bbe62df9a7c1c46b7f1f21d556deec5382a36df146fb29c7f1240e60d7d5328570e3b71d99602b77a65c9b3655f62837f8d66b59f1763b8c9beba3be07778043a37f307d307b060a2b0601040183a25a01010101ff046a3068042408011220ec8094573afb9728088860864f7bcea2d4fd412fef09a8e2d24d482377c20db60440ecabae8354afa2f0af4b8d2ad871e865cb5a7c0c8d3dbdbf42de577f92461a0ebb0a28703e33581af7d2a4f2270fc37aec6261fcc95f8af08f3f4806581c730a300a06082a8648ce3d040302034800304502202dfb17a6fa0f94ee0e2e6a3b9fb6e986f311dee27392058016464bd130930a61022100ba4b937a11c8d3172b81e7cd04aedb79b978c4379c2b5b24d565dd5d67d3cb3c" @@ -59,6 +74,7 @@ class CertificatesTest { val host = generateEd25519KeyPair() val conn = generateEd25519KeyPair() val cert = buildCert(host.first, conn.first) + getPublicKeyFromCert(arrayOf(cert)) val peerIdFromCert = verifyAndExtractPeerId(arrayOf(cert)) val expectedPeerId = PeerId.fromPubKey(host.second) assertEquals(peerIdFromCert, expectedPeerId) diff --git a/libp2p/src/testFixtures/kotlin/io/libp2p/tools/HostFactory.kt b/libp2p/src/testFixtures/kotlin/io/libp2p/tools/HostFactory.kt index 5cf216865..efb48db64 100644 --- a/libp2p/src/testFixtures/kotlin/io/libp2p/tools/HostFactory.kt +++ b/libp2p/src/testFixtures/kotlin/io/libp2p/tools/HostFactory.kt @@ -20,12 +20,13 @@ import io.libp2p.protocol.Ping import io.libp2p.security.noise.NoiseXXSecureChannel import io.libp2p.transport.tcp.TcpTransport import io.netty.handler.logging.LogLevel +import java.util.* import java.util.concurrent.TimeUnit class HostFactory { var keyType = KeyType.ECDSA - var tcpPort = 5000 + var tcpPort = Random().nextInt(10_000) + 6000 var transportCtor = ::TcpTransport var secureCtor: SecureChannelCtor = ::NoiseXXSecureChannel var mplexCtor = ::MplexStreamMuxer diff --git a/versions.gradle b/versions.gradle index 94fa371b0..8e89ed8b6 100644 --- a/versions.gradle +++ b/versions.gradle @@ -31,7 +31,7 @@ dependencyManagement { entry 'protobuf-java' entry 'protoc' } - dependencySet(group: "io.netty", version: "4.1.115.Final") { + dependencySet(group: "io.netty", version: "4.1.118.Final") { entry 'netty-common' entry 'netty-handler' entry 'netty-transport' @@ -46,5 +46,6 @@ dependencyManagement { entry 'bcpkix-jdk18on' entry 'bctls-jdk18on' } + dependency "io.netty.incubator:netty-incubator-codec-native-quic:0.0.71.Final" } } \ No newline at end of file From e7ae1b604be135c693e641b981e3055737169814 Mon Sep 17 00:00:00 2001 From: Anton Nashatyrev Date: Mon, 14 Apr 2025 13:24:56 +0400 Subject: [PATCH 02/30] Follow up to #407: refactor NettyTransport (#408) * Relay local/remote multiaddress retrieval to Transport implementations to get rid of subclass checks --- .../io/libp2p/etc/util/MultiaddrUtils.kt | 30 +++ .../implementation/ConnectionBuilder.kt | 3 +- .../implementation/ConnectionOverNetty.kt | 49 +--- .../implementation/NettyTransport.kt | 207 +--------------- .../implementation/PlainNettyTransport.kt | 222 ++++++++++++++++++ .../io/libp2p/transport/quic/QuicTransport.kt | 38 +-- .../io/libp2p/transport/tcp/TcpTransport.kt | 23 +- .../io/libp2p/transport/ws/WsTransport.kt | 23 +- .../kotlin/io/libp2p/tools/NullTransport.kt | 15 +- .../io/libp2p/simulate/util/NullTransport.kt | 13 +- 10 files changed, 323 insertions(+), 300 deletions(-) create mode 100644 libp2p/src/main/kotlin/io/libp2p/etc/util/MultiaddrUtils.kt create mode 100644 libp2p/src/main/kotlin/io/libp2p/transport/implementation/PlainNettyTransport.kt diff --git a/libp2p/src/main/kotlin/io/libp2p/etc/util/MultiaddrUtils.kt b/libp2p/src/main/kotlin/io/libp2p/etc/util/MultiaddrUtils.kt new file mode 100644 index 000000000..8b8716307 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/etc/util/MultiaddrUtils.kt @@ -0,0 +1,30 @@ +package io.libp2p.etc.util + +import io.libp2p.core.InternalErrorException +import io.libp2p.core.multiformats.Multiaddr +import io.libp2p.core.multiformats.Protocol +import java.net.* + +class MultiaddrUtils { + + companion object { + + fun inetAddressToIpMultiaddr(addr: InetAddress): Multiaddr { + val proto = when (addr) { + is Inet4Address -> Protocol.IP4 + is Inet6Address -> Protocol.IP6 + else -> throw InternalErrorException("Unknown address type $addr") + } + return Multiaddr.empty() + .withComponent(proto, addr.hostAddress) + } + + fun inetSocketAddressToTcpMultiaddr(addr: InetSocketAddress): Multiaddr = + inetAddressToIpMultiaddr(addr.address) + .withComponent(Protocol.TCP, addr.port.toString()) + + fun inetSocketAddressToUdpMultiaddr(addr: InetSocketAddress): Multiaddr = + inetAddressToIpMultiaddr(addr.address) + .withComponent(Protocol.UDP, addr.port.toString()) + } +} diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionBuilder.kt b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionBuilder.kt index 960c94f33..d4a1834d4 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionBuilder.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionBuilder.kt @@ -5,7 +5,6 @@ import io.libp2p.core.Connection import io.libp2p.core.ConnectionHandler import io.libp2p.core.P2PChannel import io.libp2p.core.PeerId -import io.libp2p.core.transport.Transport import io.libp2p.etc.REMOTE_PEER_ID import io.libp2p.etc.types.forward import io.libp2p.transport.ConnectionUpgrader @@ -14,7 +13,7 @@ import io.netty.channel.ChannelInitializer import java.util.concurrent.CompletableFuture class ConnectionBuilder( - private val transport: Transport, + private val transport: NettyTransport, private val upgrader: ConnectionUpgrader, private val connHandler: ConnectionHandler, private val initiator: Boolean, diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionOverNetty.kt b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionOverNetty.kt index 08b50a994..9572c35a6 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionOverNetty.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/ConnectionOverNetty.kt @@ -1,19 +1,11 @@ package io.libp2p.transport.implementation import io.libp2p.core.Connection -import io.libp2p.core.InternalErrorException import io.libp2p.core.multiformats.Multiaddr -import io.libp2p.core.multiformats.Protocol import io.libp2p.core.mux.StreamMuxer import io.libp2p.core.security.SecureChannel -import io.libp2p.core.transport.Transport import io.libp2p.etc.CONNECTION -import io.libp2p.transport.quic.QuicTransport import io.netty.channel.Channel -import io.netty.incubator.codec.quic.QuicChannel -import java.net.Inet4Address -import java.net.Inet6Address -import java.net.InetSocketAddress /** * A Connection is a high-level wrapper around a Netty Channel representing the conduit to a peer. @@ -22,7 +14,7 @@ import java.net.InetSocketAddress */ open class ConnectionOverNetty( ch: Channel, - private val transport: Transport, + private val nettyTransport: NettyTransport, initiator: Boolean ) : Connection, P2PChannelOverNetty(ch, initiator) { private lateinit var muxerSession: StreamMuxer.Session @@ -41,41 +33,8 @@ open class ConnectionOverNetty( override fun muxerSession() = muxerSession override fun secureSession() = secureSession - override fun transport() = transport + override fun transport() = nettyTransport - override fun localAddress(): Multiaddr { - if (nettyChannel is QuicChannel) { - return toMultiaddr(nettyChannel.localSocketAddress() as InetSocketAddress) - } else { - return toMultiaddr(nettyChannel.localAddress() as InetSocketAddress) - } - } - override fun remoteAddress(): Multiaddr { - if (nettyChannel is QuicChannel) { - return toMultiaddr(nettyChannel.remoteSocketAddress() as InetSocketAddress) - } else { - return toMultiaddr(nettyChannel.remoteAddress() as InetSocketAddress) - } - } - - private fun toMultiaddr(addr: InetSocketAddress): Multiaddr { - if (transport is NettyTransport) { - return transport.toMultiaddr(addr) - } else if (transport is QuicTransport) { - return transport.toMultiaddr(addr) - } else { - return toMultiaddrDefault(addr) - } - } - - fun toMultiaddrDefault(addr: InetSocketAddress): Multiaddr { - val proto = when (addr.address) { - is Inet4Address -> Protocol.IP4 - is Inet6Address -> Protocol.IP6 - else -> throw InternalErrorException("Unknown address type $addr") - } - return Multiaddr.empty() - .withComponent(proto, addr.address.hostAddress) - .withComponent(Protocol.TCP, addr.port.toString()) - } // toMultiaddr + override fun localAddress(): Multiaddr = nettyTransport.localAddress(nettyChannel) + override fun remoteAddress(): Multiaddr = nettyTransport.remoteAddress(nettyChannel) } diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/NettyTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/NettyTransport.kt index f29c2bfa6..2e5d9d305 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/NettyTransport.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/NettyTransport.kt @@ -1,208 +1,15 @@ package io.libp2p.transport.implementation -import io.libp2p.core.ChannelVisitor -import io.libp2p.core.Connection -import io.libp2p.core.ConnectionHandler -import io.libp2p.core.Libp2pException -import io.libp2p.core.P2PChannel -import io.libp2p.core.PeerId import io.libp2p.core.multiformats.Multiaddr -import io.libp2p.core.multiformats.MultiaddrDns -import io.libp2p.core.multiformats.Protocol import io.libp2p.core.transport.Transport -import io.libp2p.etc.types.lazyVar -import io.libp2p.etc.types.toCompletableFuture -import io.libp2p.etc.types.toVoidCompletableFuture -import io.libp2p.etc.util.netty.nettyInitializer -import io.libp2p.transport.ConnectionUpgrader -import io.netty.bootstrap.Bootstrap -import io.netty.bootstrap.ServerBootstrap import io.netty.channel.Channel -import io.netty.channel.ChannelHandler -import io.netty.channel.ChannelOption -import io.netty.channel.nio.NioEventLoopGroup -import io.netty.channel.socket.nio.NioServerSocketChannel -import io.netty.channel.socket.nio.NioSocketChannel -import java.net.InetSocketAddress -import java.time.Duration -import java.util.concurrent.CompletableFuture -abstract class NettyTransport( - private val upgrader: ConnectionUpgrader -) : Transport { - private var closed = false - var connectTimeout = Duration.ofSeconds(15) +/** + * A `Transport` which relies on a Netty `Channel` + */ +interface NettyTransport : Transport { - private val listeners = mutableMapOf() - private val channels = mutableListOf() + fun localAddress(nettyChannel: Channel): Multiaddr - private var workerGroup by lazyVar { NioEventLoopGroup() } - private var bossGroup by lazyVar { NioEventLoopGroup(1) } - - private var client by lazyVar { - Bootstrap().apply { - group(workerGroup) - channel(NioSocketChannel::class.java) - option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout.toMillis().toInt()) - } - } - - private var server by lazyVar { - ServerBootstrap().apply { - group(bossGroup, workerGroup) - channel(NioServerSocketChannel::class.java) - } - } - - override val activeListeners: Int - get() = listeners.size - override val activeConnections: Int - get() = channels.size - - override fun listenAddresses(): List { - return listeners.values.map { - toMultiaddr(it.localAddress() as InetSocketAddress) - } - } - - override fun initialize() { - } - - override fun close(): CompletableFuture { - closed = true - - val unbindsCompleted = listeners - .map { (_, ch) -> ch } - .map { it.close().toVoidCompletableFuture() } - - val channelsClosed = channels - .toMutableList() // need a copy to avoid potential co-modification problems - .map { it.close().toVoidCompletableFuture() } - - val everythingThatNeedsToClose = unbindsCompleted.union(channelsClosed) - val allClosed = CompletableFuture.allOf(*everythingThatNeedsToClose.toTypedArray()) - - return allClosed.thenApply { - workerGroup.shutdownGracefully() - bossGroup.shutdownGracefully() - Unit - } - } // close - - override fun listen(addr: Multiaddr, connHandler: ConnectionHandler, preHandler: ChannelVisitor?): CompletableFuture { - if (closed) throw Libp2pException("Transport is closed") - - val connectionBuilder = makeConnectionBuilder(connHandler, false, preHandler = preHandler) - val channelHandler = serverTransportBuilder(connectionBuilder, addr) ?: connectionBuilder - - val listener = server.clone() - .childHandler( - nettyInitializer { init -> - registerChannel(init.channel) - init.addLastLocal(channelHandler) - } - ) - - val bindComplete = listener.bind(fromMultiaddr(addr)) - - bindComplete.also { - synchronized(this@NettyTransport) { - listeners += addr to it.channel() - it.channel().closeFuture().addListener { - synchronized(this@NettyTransport) { - listeners -= addr - } - } - } - } - - return bindComplete.toVoidCompletableFuture() - } // listener - - protected abstract fun serverTransportBuilder( - connectionBuilder: ConnectionBuilder, - addr: Multiaddr - ): ChannelHandler? - - override fun unlisten(addr: Multiaddr): CompletableFuture { - return listeners[addr]?.close()?.toVoidCompletableFuture() - ?: throw Libp2pException("No listeners on address $addr") - } // unlisten - - override fun dial(addr: Multiaddr, connHandler: ConnectionHandler, preHandler: ChannelVisitor?): CompletableFuture { - if (closed) throw Libp2pException("Transport is closed") - - val remotePeerId = addr.getPeerId() - val connectionBuilder = makeConnectionBuilder(connHandler, true, remotePeerId, preHandler) - val channelHandler = clientTransportBuilder(connectionBuilder, addr) ?: connectionBuilder - - val chanFuture = client.clone() - .handler(channelHandler) - .connect(fromMultiaddr(addr)) - .also { registerChannel(it.channel()) } - - return chanFuture.toCompletableFuture() - .thenCompose { connectionBuilder.connectionEstablished } - } // dial - - protected abstract fun clientTransportBuilder( - connectionBuilder: ConnectionBuilder, - addr: Multiaddr - ): ChannelHandler? - - private fun registerChannel(ch: Channel) { - if (closed) { - ch.close() - return - } - - synchronized(this@NettyTransport) { - channels += ch - ch.closeFuture().addListener { - synchronized(this@NettyTransport) { - channels -= ch - } - } - } - } // registerChannel - - private fun makeConnectionBuilder( - connHandler: ConnectionHandler, - initiator: Boolean, - remotePeerId: PeerId? = null, - preHandler: ChannelVisitor? - ) = ConnectionBuilder( - this, - upgrader, - connHandler, - initiator, - remotePeerId, - preHandler - ) - - protected fun handlesHost(addr: Multiaddr) = - addr.hasAny(Protocol.IP4, Protocol.IP6, Protocol.DNS4, Protocol.DNS6, Protocol.DNSADDR) - - protected fun hostFromMultiaddr(addr: Multiaddr): String { - val resolvedAddresses = MultiaddrDns.resolve(addr) - if (resolvedAddresses.isEmpty()) { - throw Libp2pException("Could not resolve $addr to an IP address") - } - - return resolvedAddresses[0].components.find { - it.protocol in arrayOf(Protocol.IP4, Protocol.IP6) - }?.stringValue ?: throw Libp2pException("Missing IP4/IP6 in multiaddress $addr") - } - - protected fun portFromMultiaddr(addr: Multiaddr) = - addr.components.find { p -> p.protocol == Protocol.TCP } - ?.stringValue?.toInt() ?: throw Libp2pException("Missing TCP in multiaddress $addr") - - private fun fromMultiaddr(addr: Multiaddr): InetSocketAddress { - val host = hostFromMultiaddr(addr) - val port = portFromMultiaddr(addr) - return InetSocketAddress(host, port) - } // fromMultiaddr - - abstract fun toMultiaddr(addr: InetSocketAddress): Multiaddr -} // class NettyTransportBase + fun remoteAddress(nettyChannel: Channel): Multiaddr +} diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/PlainNettyTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/PlainNettyTransport.kt new file mode 100644 index 000000000..96931e4ec --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/PlainNettyTransport.kt @@ -0,0 +1,222 @@ +package io.libp2p.transport.implementation + +import io.libp2p.core.ChannelVisitor +import io.libp2p.core.Connection +import io.libp2p.core.ConnectionHandler +import io.libp2p.core.Libp2pException +import io.libp2p.core.P2PChannel +import io.libp2p.core.PeerId +import io.libp2p.core.multiformats.Multiaddr +import io.libp2p.core.multiformats.MultiaddrDns +import io.libp2p.core.multiformats.Protocol +import io.libp2p.etc.types.lazyVar +import io.libp2p.etc.types.toCompletableFuture +import io.libp2p.etc.types.toVoidCompletableFuture +import io.libp2p.etc.util.netty.nettyInitializer +import io.libp2p.transport.ConnectionUpgrader +import io.netty.bootstrap.Bootstrap +import io.netty.bootstrap.ServerBootstrap +import io.netty.channel.Channel +import io.netty.channel.ChannelHandler +import io.netty.channel.ChannelOption +import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.socket.nio.NioServerSocketChannel +import io.netty.channel.socket.nio.NioSocketChannel +import java.net.InetSocketAddress +import java.net.SocketAddress +import java.time.Duration +import java.util.concurrent.CompletableFuture + +/** + * A plain `NettyTransport` without embedded security and muxer + */ +abstract class PlainNettyTransport( + private val upgrader: ConnectionUpgrader +) : NettyTransport { // class NettyTransportBase + private var closed = false + var connectTimeout = Duration.ofSeconds(15) + + private val listeners = mutableMapOf() + private val channels = mutableListOf() + + private var workerGroup by lazyVar { NioEventLoopGroup() } + private var bossGroup by lazyVar { NioEventLoopGroup(1) } + + private var client by lazyVar { + Bootstrap().apply { + group(workerGroup) + channel(NioSocketChannel::class.java) + option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout.toMillis().toInt()) + } + } + + private var server by lazyVar { + ServerBootstrap().apply { + group(bossGroup, workerGroup) + channel(NioServerSocketChannel::class.java) + } + } + + override val activeListeners: Int + get() = listeners.size + override val activeConnections: Int + get() = channels.size + + override fun listenAddresses(): List { + return listeners.values.map { + toMultiaddr(it.localAddress() as InetSocketAddress) + } + } + + override fun initialize() { + } + + override fun close(): CompletableFuture { + closed = true + + val unbindsCompleted = listeners + .map { (_, ch) -> ch } + .map { it.close().toVoidCompletableFuture() } + + val channelsClosed = channels + .toMutableList() // need a copy to avoid potential co-modification problems + .map { it.close().toVoidCompletableFuture() } + + val everythingThatNeedsToClose = unbindsCompleted.union(channelsClosed) + val allClosed = CompletableFuture.allOf(*everythingThatNeedsToClose.toTypedArray()) + + return allClosed.thenApply { + workerGroup.shutdownGracefully() + bossGroup.shutdownGracefully() + Unit + } + } // close + + override fun listen( + addr: Multiaddr, + connHandler: ConnectionHandler, + preHandler: ChannelVisitor? + ): CompletableFuture { + if (closed) throw Libp2pException("Transport is closed") + + val connectionBuilder = makeConnectionBuilder(connHandler, false, preHandler = preHandler) + val channelHandler = serverTransportBuilder(connectionBuilder, addr) ?: connectionBuilder + + val listener = server.clone() + .childHandler( + nettyInitializer { init -> + registerChannel(init.channel) + init.addLastLocal(channelHandler) + } + ) + + val bindComplete = listener.bind(fromMultiaddr(addr)) + + bindComplete.also { + synchronized(this@PlainNettyTransport) { + listeners += addr to it.channel() + it.channel().closeFuture().addListener { + synchronized(this@PlainNettyTransport) { + listeners -= addr + } + } + } + } + + return bindComplete.toVoidCompletableFuture() + } // listener + + protected abstract fun serverTransportBuilder( + connectionBuilder: ConnectionBuilder, + addr: Multiaddr + ): ChannelHandler? + + override fun unlisten(addr: Multiaddr): CompletableFuture { + return listeners[addr]?.close()?.toVoidCompletableFuture() + ?: throw Libp2pException("No listeners on address $addr") + } // unlisten + + override fun dial( + addr: Multiaddr, + connHandler: ConnectionHandler, + preHandler: ChannelVisitor? + ): CompletableFuture { + if (closed) throw Libp2pException("Transport is closed") + + val remotePeerId = addr.getPeerId() + val connectionBuilder = makeConnectionBuilder(connHandler, true, remotePeerId, preHandler) + val channelHandler = clientTransportBuilder(connectionBuilder, addr) ?: connectionBuilder + + val chanFuture = client.clone() + .handler(channelHandler) + .connect(fromMultiaddr(addr)) + .also { registerChannel(it.channel()) } + + return chanFuture.toCompletableFuture() + .thenCompose { connectionBuilder.connectionEstablished } + } // dial + + protected abstract fun clientTransportBuilder( + connectionBuilder: ConnectionBuilder, + addr: Multiaddr + ): ChannelHandler? + + private fun registerChannel(ch: Channel) { + if (closed) { + ch.close() + return + } + + synchronized(this@PlainNettyTransport) { + channels += ch + ch.closeFuture().addListener { + synchronized(this@PlainNettyTransport) { + channels -= ch + } + } + } + } // registerChannel + + private fun makeConnectionBuilder( + connHandler: ConnectionHandler, + initiator: Boolean, + remotePeerId: PeerId? = null, + preHandler: ChannelVisitor? + ) = ConnectionBuilder( + this, + upgrader, + connHandler, + initiator, + remotePeerId, + preHandler + ) + + protected fun handlesHost(addr: Multiaddr) = + addr.hasAny(Protocol.IP4, Protocol.IP6, Protocol.DNS4, Protocol.DNS6, Protocol.DNSADDR) + + protected fun hostFromMultiaddr(addr: Multiaddr): String { + val resolvedAddresses = MultiaddrDns.resolve(addr) + if (resolvedAddresses.isEmpty()) { + throw Libp2pException("Could not resolve $addr to an IP address") + } + + return resolvedAddresses[0].components.find { + it.protocol in arrayOf(Protocol.IP4, Protocol.IP6) + }?.stringValue ?: throw Libp2pException("Missing IP4/IP6 in multiaddress $addr") + } + + protected fun portFromMultiaddr(addr: Multiaddr) = + addr.components.find { p -> p.protocol == Protocol.TCP } + ?.stringValue?.toInt() ?: throw Libp2pException("Missing TCP in multiaddress $addr") + + private fun fromMultiaddr(addr: Multiaddr): InetSocketAddress { + val host = hostFromMultiaddr(addr) + val port = portFromMultiaddr(addr) + return InetSocketAddress(host, port) + } // fromMultiaddr + + override fun localAddress(nettyChannel: Channel): Multiaddr = toMultiaddr(nettyChannel.localAddress()) + override fun remoteAddress(nettyChannel: Channel): Multiaddr = toMultiaddr(nettyChannel.remoteAddress()) + + abstract fun toMultiaddr(addr: SocketAddress): Multiaddr +} diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt index 72d7fac4e..2574e4fc9 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt @@ -12,15 +12,16 @@ import io.libp2p.core.multistream.MultistreamProtocolV1 import io.libp2p.core.multistream.ProtocolBinding import io.libp2p.core.mux.StreamMuxer import io.libp2p.core.security.SecureChannel -import io.libp2p.core.transport.Transport import io.libp2p.crypto.keys.generateEcdsaKeyPair import io.libp2p.crypto.keys.generateEd25519KeyPair import io.libp2p.etc.CONNECTION import io.libp2p.etc.STREAM import io.libp2p.etc.types.* +import io.libp2p.etc.util.MultiaddrUtils import io.libp2p.etc.util.netty.nettyInitializer import io.libp2p.security.tls.* import io.libp2p.transport.implementation.ConnectionOverNetty +import io.libp2p.transport.implementation.NettyTransport import io.libp2p.transport.implementation.StreamOverNetty import io.netty.bootstrap.Bootstrap import io.netty.buffer.ByteBuf @@ -42,7 +43,7 @@ class QuicTransport( private val localKey: PrivKey, private val certAlgorithm: String, private val protocols: List> -) : Transport { +) : NettyTransport { private var closed = false var connectTimeout = Duration.ofSeconds(15) @@ -174,7 +175,11 @@ class QuicTransport( ?: throw Libp2pException("No listeners on address $addr") } - override fun dial(addr: Multiaddr, connHandler: ConnectionHandler, preHandler: ChannelVisitor?): CompletableFuture { + override fun dial( + addr: Multiaddr, + connHandler: ConnectionHandler, + preHandler: ChannelVisitor? + ): CompletableFuture { if (closed) throw Libp2pException("Transport is closed") val trust = Libp2pTrustManager(Optional.ofNullable(addr.getPeerId())) @@ -230,7 +235,8 @@ class QuicTransport( val stream = createStream(ctx!!.channel(), connection, true) ctx.channel().attr(STREAM).set(stream) val streamHandler = multi.toStreamHandler() - streamHandler.handleStream(stream).forward(controller).apply { streamFut.complete(stream) } + streamHandler.handleStream(stream).forward(controller) + .apply { streamFut.complete(stream) } } } ) @@ -287,6 +293,7 @@ class QuicTransport( it.protocol in arrayOf(IP4, IP6) }?.stringValue ?: throw Libp2pException("Missing IP4/IP6 in multiaddress $addr") } + override fun handles(addr: Multiaddr) = handlesHost(addr) && addr.has(UDP) && @@ -326,7 +333,10 @@ class QuicTransport( .build() } - fun serverTransportBuilder(connHandler: ConnectionHandler, preHandler: ChannelVisitor?): ChannelHandler { + fun serverTransportBuilder( + connHandler: ConnectionHandler, + preHandler: ChannelVisitor? + ): ChannelHandler { val sslContext = quicSslContext(null, Libp2pTrustManager(Optional.empty())) return QuicServerCodecBuilder() .sslEngineProvider({ q -> sslContext.newEngine(q.alloc()) }) @@ -385,15 +395,13 @@ class QuicTransport( return InetSocketAddress(host, port) } - fun toMultiaddr(addr: InetSocketAddress): Multiaddr { - val proto = when (addr.address) { - is Inet4Address -> IP4 - is Inet6Address -> IP6 - else -> throw InternalErrorException("Unknown address type $addr") - } - return Multiaddr.empty() - .withComponent(proto, addr.address.hostAddress) - .withComponent(UDP, addr.port.toString()) + override fun localAddress(nettyChannel: Channel): Multiaddr = + toMultiaddr((nettyChannel as QuicChannel).localSocketAddress()!!) + + override fun remoteAddress(nettyChannel: Channel): Multiaddr = + toMultiaddr((nettyChannel as QuicChannel).remoteSocketAddress()!!) + + fun toMultiaddr(addr: SocketAddress): Multiaddr = + MultiaddrUtils.inetSocketAddressToUdpMultiaddr(addr as InetSocketAddress) .withComponent(QUICV1) - } } diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/tcp/TcpTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/tcp/TcpTransport.kt index a081ff67d..375c916d4 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/tcp/TcpTransport.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/tcp/TcpTransport.kt @@ -1,20 +1,17 @@ package io.libp2p.transport.tcp -import io.libp2p.core.InternalErrorException import io.libp2p.core.multiformats.Multiaddr import io.libp2p.core.multiformats.Protocol.DNSADDR -import io.libp2p.core.multiformats.Protocol.IP4 -import io.libp2p.core.multiformats.Protocol.IP6 import io.libp2p.core.multiformats.Protocol.P2PCIRCUIT import io.libp2p.core.multiformats.Protocol.TCP import io.libp2p.core.multiformats.Protocol.WS +import io.libp2p.etc.util.MultiaddrUtils import io.libp2p.transport.ConnectionUpgrader import io.libp2p.transport.implementation.ConnectionBuilder -import io.libp2p.transport.implementation.NettyTransport +import io.libp2p.transport.implementation.PlainNettyTransport import io.netty.channel.ChannelHandler -import java.net.Inet4Address -import java.net.Inet6Address import java.net.InetSocketAddress +import java.net.SocketAddress /** * The TCP transport can establish libp2p connections via TCP endpoints. @@ -24,7 +21,7 @@ import java.net.InetSocketAddress */ open class TcpTransport( upgrader: ConnectionUpgrader -) : NettyTransport(upgrader) { +) : PlainNettyTransport(upgrader) { override fun handles(addr: Multiaddr) = handlesHost(addr) && @@ -43,14 +40,6 @@ open class TcpTransport( addr: Multiaddr ): ChannelHandler? = null - override fun toMultiaddr(addr: InetSocketAddress): Multiaddr { - val proto = when (addr.address) { - is Inet4Address -> IP4 - is Inet6Address -> IP6 - else -> throw InternalErrorException("Unknown address type $addr") - } - return Multiaddr.empty() - .withComponent(proto, addr.address.hostAddress) - .withComponent(TCP, addr.port.toString()) - } // toMultiaddr + override fun toMultiaddr(addr: SocketAddress): Multiaddr = + MultiaddrUtils.inetSocketAddressToTcpMultiaddr(addr as InetSocketAddress) } // class TcpTransport diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/ws/WsTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/ws/WsTransport.kt index 431c30d11..8afdca5d6 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/ws/WsTransport.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/ws/WsTransport.kt @@ -1,18 +1,15 @@ package io.libp2p.transport.ws -import io.libp2p.core.InternalErrorException import io.libp2p.core.multiformats.Multiaddr -import io.libp2p.core.multiformats.Protocol.IP4 -import io.libp2p.core.multiformats.Protocol.IP6 import io.libp2p.core.multiformats.Protocol.TCP import io.libp2p.core.multiformats.Protocol.WS +import io.libp2p.etc.util.MultiaddrUtils import io.libp2p.transport.ConnectionUpgrader import io.libp2p.transport.implementation.ConnectionBuilder -import io.libp2p.transport.implementation.NettyTransport +import io.libp2p.transport.implementation.PlainNettyTransport import io.netty.channel.ChannelHandler -import java.net.Inet4Address -import java.net.Inet6Address import java.net.InetSocketAddress +import java.net.SocketAddress /** * The WS transport can establish libp2p connections @@ -20,7 +17,7 @@ import java.net.InetSocketAddress */ class WsTransport( upgrader: ConnectionUpgrader -) : NettyTransport(upgrader) { +) : PlainNettyTransport(upgrader) { override fun handles(addr: Multiaddr) = handlesHost(addr) && @@ -45,15 +42,7 @@ class WsTransport( return WebSocketClientInitializer(connectionBuilder, url) } // clientTransportBuilder - override fun toMultiaddr(addr: InetSocketAddress): Multiaddr { - val proto = when (addr.address) { - is Inet4Address -> IP4 - is Inet6Address -> IP6 - else -> throw InternalErrorException("Unknown address type $addr") - } - return Multiaddr.empty() - .withComponent(proto, addr.address.hostAddress) - .withComponent(TCP, addr.port.toString()) + override fun toMultiaddr(addr: SocketAddress): Multiaddr = + MultiaddrUtils.inetSocketAddressToTcpMultiaddr(addr as InetSocketAddress) .withComponent(WS) - } // toMultiaddr } // class WsTransport diff --git a/libp2p/src/testFixtures/kotlin/io/libp2p/tools/NullTransport.kt b/libp2p/src/testFixtures/kotlin/io/libp2p/tools/NullTransport.kt index f50b4e201..81c10441f 100644 --- a/libp2p/src/testFixtures/kotlin/io/libp2p/tools/NullTransport.kt +++ b/libp2p/src/testFixtures/kotlin/io/libp2p/tools/NullTransport.kt @@ -5,10 +5,13 @@ import io.libp2p.core.Connection import io.libp2p.core.ConnectionHandler import io.libp2p.core.P2PChannel import io.libp2p.core.multiformats.Multiaddr -import io.libp2p.core.transport.Transport +import io.libp2p.etc.util.MultiaddrUtils +import io.libp2p.transport.implementation.NettyTransport +import io.netty.channel.Channel +import java.net.InetSocketAddress import java.util.concurrent.CompletableFuture -class NullTransport : Transport { +class NullTransport : NettyTransport { override val activeConnections: Int get() = stub() override val activeListeners: Int @@ -22,14 +25,22 @@ class NullTransport : Transport { connHandler: ConnectionHandler, preHandler: ChannelVisitor? ): CompletableFuture = stub() + override fun unlisten(addr: Multiaddr): CompletableFuture = stub() override fun dial( addr: Multiaddr, connHandler: ConnectionHandler, preHandler: ChannelVisitor? ): CompletableFuture = stub() + override fun handles(addr: Multiaddr): Boolean = stub() + override fun localAddress(nettyChannel: Channel): Multiaddr = + MultiaddrUtils.inetSocketAddressToTcpMultiaddr(nettyChannel.localAddress() as InetSocketAddress) + + override fun remoteAddress(nettyChannel: Channel): Multiaddr = + MultiaddrUtils.inetSocketAddressToTcpMultiaddr(nettyChannel.remoteAddress() as InetSocketAddress) + private fun stub(): Nothing { throw NotImplementedError("Test stub") } diff --git a/tools/simulator/src/main/kotlin/io/libp2p/simulate/util/NullTransport.kt b/tools/simulator/src/main/kotlin/io/libp2p/simulate/util/NullTransport.kt index 538a9c273..91a51699d 100644 --- a/tools/simulator/src/main/kotlin/io/libp2p/simulate/util/NullTransport.kt +++ b/tools/simulator/src/main/kotlin/io/libp2p/simulate/util/NullTransport.kt @@ -5,10 +5,13 @@ import io.libp2p.core.Connection import io.libp2p.core.ConnectionHandler import io.libp2p.core.P2PChannel import io.libp2p.core.multiformats.Multiaddr -import io.libp2p.core.transport.Transport +import io.libp2p.etc.util.MultiaddrUtils +import io.libp2p.transport.implementation.NettyTransport +import io.netty.channel.Channel +import java.net.InetSocketAddress import java.util.concurrent.CompletableFuture -class NullTransport : Transport { +class NullTransport : NettyTransport { override val activeConnections: Int get() = stub() override val activeListeners: Int @@ -30,6 +33,12 @@ class NullTransport : Transport { ): CompletableFuture = stub() override fun handles(addr: Multiaddr): Boolean = stub() + override fun localAddress(nettyChannel: Channel): Multiaddr = + MultiaddrUtils.inetSocketAddressToTcpMultiaddr(nettyChannel.localAddress() as InetSocketAddress) + + override fun remoteAddress(nettyChannel: Channel): Multiaddr = + MultiaddrUtils.inetSocketAddressToTcpMultiaddr(nettyChannel.remoteAddress() as InetSocketAddress) + private fun stub(): Nothing { throw NotImplementedError("Test stub") } From cae10367c057825787b0748757d36f2d7c0661c8 Mon Sep 17 00:00:00 2001 From: web3-bot <81333946+web3-bot@users.noreply.github.com> Date: Thu, 1 May 2025 09:31:17 +0200 Subject: [PATCH 03/30] ci: uci/copy-templates (#410) * chore: add or force update .github/workflows/stale.yml * chore: add or force update .github/workflows/generated-pr.yml --- .github/workflows/generated-pr.yml | 14 ++++++++++++++ .github/workflows/stale.yml | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/generated-pr.yml diff --git a/.github/workflows/generated-pr.yml b/.github/workflows/generated-pr.yml new file mode 100644 index 000000000..b8c5cc631 --- /dev/null +++ b/.github/workflows/generated-pr.yml @@ -0,0 +1,14 @@ +name: Close Generated PRs + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + uses: ipdxco/unified-github-workflows/.github/workflows/reusable-generated-pr.yml@v1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 16d65d721..7c955c414 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,8 +1,9 @@ -name: Close and mark stale issue +name: Close Stale Issues on: schedule: - cron: '0 0 * * *' + workflow_dispatch: permissions: issues: write @@ -10,4 +11,4 @@ permissions: jobs: stale: - uses: pl-strflt/.github/.github/workflows/reusable-stale-issue.yml@v0.3 + uses: ipdxco/unified-github-workflows/.github/workflows/reusable-stale-issue.yml@v1 From 8b62e1fc1b277c016d0806b1f63fbfddd54c1446 Mon Sep 17 00:00:00 2001 From: Stefan Bratanov Date: Wed, 28 May 2025 14:48:53 +0100 Subject: [PATCH 04/30] Add QUIC os-specific bindings as implementation (#411) --- examples/android-chatter/build.gradle | 8 ++++++++ libp2p/build.gradle.kts | 22 +++++++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/examples/android-chatter/build.gradle b/examples/android-chatter/build.gradle index 41dd1bfcd..f354ded48 100644 --- a/examples/android-chatter/build.gradle +++ b/examples/android-chatter/build.gradle @@ -25,6 +25,14 @@ android { exclude 'META-INF/io.netty.versions.properties' exclude 'META-INF/INDEX.LIST' exclude 'META-INF/versions/9/OSGI-INF/MANIFEST.MF' + exclude 'META-INF/license/LICENSE.aix-netbsd.txt' + exclude 'META-INF/native-image/io.netty.incubator/netty-incubator-codec-native-quic/jni-config.json' + exclude 'META-INF/native-image/io.netty.incubator/netty-incubator-codec-native-quic/reflect-config.json' + exclude 'META-INF/native-image/io.netty.incubator/netty-incubator-codec-native-quic/resource-config.json' + exclude 'META-INF/license/LICENSE.boringssl.txt' + exclude 'META-INF/license/LICENSE.mvn-wrapper.txt' + exclude 'META-INF/license/LICENSE.quiche.txt' + exclude 'META-INF/license/LICENSE.tomcat-native.txt' } kotlinOptions { jvmTarget = "11" diff --git a/libp2p/build.gradle.kts b/libp2p/build.gradle.kts index 801930267..44b9a74b2 100644 --- a/libp2p/build.gradle.kts +++ b/libp2p/build.gradle.kts @@ -16,6 +16,17 @@ dependencies { implementation("io.netty:netty-codec-http") implementation("io.netty:netty-transport-classes-epoll") implementation("io.netty.incubator:netty-incubator-codec-native-quic") + // OS-specific bindings + implementation("io.netty.incubator:netty-incubator-codec-native-quic::linux-x86_64") + implementation("io.netty.incubator:netty-incubator-codec-native-quic::linux-aarch_64") + implementation("io.netty.incubator:netty-incubator-codec-native-quic::osx-x86_64") + implementation("io.netty.incubator:netty-incubator-codec-native-quic::osx-aarch_64") + implementation("io.netty.incubator:netty-incubator-codec-native-quic::windows-x86_64") + implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:linux-x86_64") + implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:linux-aarch_64") + implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:osx-x86_64") + implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:osx-aarch_64") + implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:windows-x86_64") api("com.google.protobuf:protobuf-java") @@ -27,17 +38,6 @@ dependencies { testImplementation(project(":tools:schedulers")) - testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::linux-x86_64") - testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::linux-aarch_64") - testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::osx-x86_64") - testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::osx-aarch_64") - testImplementation("io.netty.incubator:netty-incubator-codec-native-quic::windows-x86_64") - testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:linux-x86_64") - testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:linux-aarch_64") - testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:osx-x86_64") - testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:osx-aarch_64") - testImplementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:windows-x86_64") - testFixturesApi("org.apache.logging.log4j:log4j-core") testFixturesImplementation(project(":tools:schedulers")) testFixturesImplementation("io.netty:netty-transport-classes-epoll") From e4c25d5994a359872763c73c17c452bb38d49d39 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Tue, 26 Aug 2025 21:13:50 +1200 Subject: [PATCH 05/30] Add interop test support (#421) --- .gitignore | 3 + build.gradle.kts | 2 +- interop-test-client/build.gradle.kts | 31 +++ .../io/libp2p/interop/InteropTestAgent.kt | 236 ++++++++++++++++++ .../io/libp2p/interop/InteropTestParams.kt | 93 +++++++ .../src/test/resources/compose.yaml | 30 +++ settings.gradle | 1 + 7 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 interop-test-client/build.gradle.kts create mode 100644 interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt create mode 100644 interop-test-client/src/main/java/io/libp2p/interop/InteropTestParams.kt create mode 100644 interop-test-client/src/test/resources/compose.yaml diff --git a/.gitignore b/.gitignore index a939ebb2f..5b5778198 100644 --- a/.gitignore +++ b/.gitignore @@ -190,3 +190,6 @@ $RECYCLE.BIN/ node_modules package-lock.json /src/jmh/java/generated/ + +#Jenv +.java-version \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index b312d3b81..7d11e4769 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -187,7 +187,7 @@ configure( } detekt { - config = files("$rootDir/detekt/config.yml") + config.from("$rootDir/detekt/config.yml") buildUponDefaultConfig = true } } diff --git a/interop-test-client/build.gradle.kts b/interop-test-client/build.gradle.kts new file mode 100644 index 000000000..df8a9ecf6 --- /dev/null +++ b/interop-test-client/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("java") + id("com.bmuschko.docker-java-application") version "9.4.0" +} + +dependencies { + implementation(project(":libp2p")) + implementation("redis.clients:jedis:6.1.0") + runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl") +} + +docker { + javaApplication { + baseImage.set("openjdk:11-jdk") + ports.set(listOf(4041)) + } +} + +val composeFileSpec: CopySpec = copySpec { + from("src/test/resources") + include("compose.yaml") +} + +val copyAssets = tasks.register("copyAssets") { + into(layout.buildDirectory.dir("docker")) + with(composeFileSpec) +} + +tasks.dockerCreateDockerfile { + dependsOn(copyAssets) +} \ No newline at end of file diff --git a/interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt b/interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt new file mode 100644 index 000000000..45ec9a9d5 --- /dev/null +++ b/interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt @@ -0,0 +1,236 @@ +package io.libp2p.interop + +import identify.pb.IdentifyOuterClass +import io.libp2p.core.Connection +import io.libp2p.core.ConnectionHandler +import io.libp2p.core.Host +import io.libp2p.core.PeerId.Companion.fromPubKey +import io.libp2p.core.crypto.PrivKey +import io.libp2p.core.dsl.Builder +import io.libp2p.core.dsl.hostJ +import io.libp2p.core.multiformats.Multiaddr +import io.libp2p.core.multistream.ProtocolBinding +import io.libp2p.core.mux.StreamMuxerProtocol +import io.libp2p.core.mux.StreamMuxerProtocol.Companion.Mplex +import io.libp2p.core.mux.StreamMuxerProtocol.Companion.getYamux +import io.libp2p.crypto.keys.generateEd25519KeyPair +import io.libp2p.etc.types.toProtobuf +import io.libp2p.protocol.Identify +import io.libp2p.protocol.Ping +import io.libp2p.security.noise.NoiseXXSecureChannel +import io.libp2p.security.tls.TlsSecureChannel.Companion.ECDSA +import io.libp2p.transport.tcp.TcpTransport +import redis.clients.jedis.Jedis +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.stream.Collectors +import kotlin.random.Random +import kotlin.system.exitProcess + +private const val REDIS_KEY_LISTENER_ADDRESS = "listenerAddr" + +class InteropTestAgent(val params: InteropTestParams) { + + private val advertisedAddress: Multiaddr + private val node: Host + + init { + val port = 10000 + Random.nextInt(50000) + val isTcp = "tcp" == params.transport + val ip = params.ip + val protocol = if (isTcp) "tcp" else "udp" + val maybeQuicSuffix = (if (isTcp) "" else "/quic-v1") + val address = + Multiaddr.fromString("/ip4/$ip/$protocol/${port}$maybeQuicSuffix") + + val privateKey = generateEd25519KeyPair().first + val peerID = fromPubKey(privateKey.publicKey()) + advertisedAddress = address.withP2P(peerID) + + val listenAddresses = ArrayList() + listenAddresses.add(address.toString()) + val protocols = createProtocols(privateKey, listenAddresses) + node = createHost(privateKey, protocols, listenAddresses) + } + + fun run(): CompletableFuture { + return node.start() + .thenCompose { startJedisConnection() } + .thenCompose { jedis -> + if (params.isDialer) { + startDialer(jedis, node) + } else { + startListener(jedis, advertisedAddress) + } + }.whenComplete { _, _ -> node.stop() } + } + + private fun createHost( + privateKey: PrivKey, + protocols: ArrayList>, + listenAddresses: ArrayList + ): Host = hostJ(Builder.Defaults.None, fn = { + it.identity.factory = { privateKey } + if (params.transport == "quic-v1") { + // TODO add quic support + } else { + it.transports.add(::TcpTransport) + } + + if ("noise" == params.security) { + it.secureChannels.add(::NoiseXXSecureChannel) + } else if ("tls" == params.security) { + it.secureChannels.add(::ECDSA) + } + + val muxers = ArrayList() + if ("mplex" == params.muxer) { + muxers.add(Mplex) + } else if ("yamux" == params.muxer) { + muxers.add(getYamux()) + } + it.muxers.addAll(muxers) + + for (protocol in protocols) { + it.protocols.add(protocol) + } + + for (listenAddr in listenAddresses) { + it.network.listen(listenAddr) + } + + it.connectionHandlers.add { + ConnectionHandler { conn: Connection -> + printDiagnosticsLog( + ( + conn.localAddress() + .toString() + " received connection from " + + conn.remoteAddress() + + " on transport " + + conn.transport() + ) + ) + } + } + }) + + private fun startJedisConnection(): CompletableFuture { + return CompletableFuture.supplyAsync { + val jedis = Jedis("http://${params.redisAddress}") + var isReady = false + while (!isReady) { + if ("PONG" == jedis.ping()) { + isReady = true + } else { + printDiagnosticsLog("waiting for redis to start...") + Thread.sleep(1000) + } + } + printDiagnosticsLog("Connection established to Redis ($jedis)") + jedis + } + } + + /* + Start dialer and try to connect with a listener + */ + private fun startDialer(jedis: Jedis, node: Host): CompletableFuture { + return CompletableFuture.supplyAsync { + printDiagnosticsLog("Starting dialer") + + val listenerAddresses = + jedis.blpop(params.testTimeoutInSeconds, REDIS_KEY_LISTENER_ADDRESS) + if (listenerAddresses == null || listenerAddresses.isEmpty()) { + throw IllegalStateException("listenerAddr not set") + } + + val listenerAddr = + Multiaddr.fromString(listenerAddresses.first { s -> s.startsWith("/") }) + + printDiagnosticsLog("Sending ping messages to $listenerAddr") + + val handshakeStart = System.currentTimeMillis() + + val pingController = Ping().dial(node, listenerAddr).controller.join() + val pingRTTMillis = pingController.ping().join() + val handshakeEnd = System.currentTimeMillis() + val handshakePlusOneRTT = handshakeEnd - handshakeStart + + printDiagnosticsLog("Ping latency $pingRTTMillis ms") + + val jsonResult = + "{\"handshakePlusOneRTTMillis\":${handshakePlusOneRTT.toDouble()}, \"pingRTTMilllis\": ${pingRTTMillis.toDouble()}}" + + emitResult(jsonResult) + null + } + } + + /* + Start listener and wait up to testTimeoutInSeconds for a message from dialer + */ + private fun startListener( + jedis: Jedis, + advertisedAddress: Multiaddr + ): CompletableFuture { + return CompletableFuture.supplyAsync { + println("Starting listener with advertisedAddress: $advertisedAddress") + + jedis.rpush(REDIS_KEY_LISTENER_ADDRESS, advertisedAddress.toString()) + + // Wait for dialer + Thread.sleep(params.testTimeoutInSeconds.toLong() * 1000L) + null + } + } + + private fun createProtocols( + privateKey: PrivKey, + listenAddresses: ArrayList + ): ArrayList> { + var identifyBuilder = + IdentifyOuterClass.Identify.newBuilder() + .setProtocolVersion("ipfs/0.1.0") + .setAgentVersion("jvm-libp2p/v1.0.0") + .setPublicKey(privateKey.publicKey().bytes().toProtobuf()) + .addAllListenAddrs( + listenAddresses.stream() + .map(Multiaddr::fromString) + .map(Multiaddr::serialize) + .map(ByteArray::toProtobuf) + .collect(Collectors.toList()) + ) + + val protocols = ArrayList>() + protocols.add(Ping()) + for (protocol in protocols) { + identifyBuilder = + identifyBuilder.addAllProtocols(protocol.protocolDescriptor.announceProtocols) + } + protocols.add(Identify(identifyBuilder.build())) + + return protocols + } +} + +private fun emitResult(json: String) { + println(json) +} + +private fun printDiagnosticsLog(msg: String) { + System.err.println(msg) +} + +@SuppressWarnings("unused") +fun main() { + try { + val params = InteropTestParams.Builder().fromEnvironmentVariables().build() + + InteropTestAgent(params).run() + .orTimeout(params.testTimeoutInSeconds.toLong(), TimeUnit.SECONDS) + .join() + } catch (e: Exception) { + printDiagnosticsLog("Unexpected exit: $e") + exitProcess(-1) + } +} diff --git a/interop-test-client/src/main/java/io/libp2p/interop/InteropTestParams.kt b/interop-test-client/src/main/java/io/libp2p/interop/InteropTestParams.kt new file mode 100644 index 000000000..86358d69b --- /dev/null +++ b/interop-test-client/src/main/java/io/libp2p/interop/InteropTestParams.kt @@ -0,0 +1,93 @@ +package io.libp2p.interop + +import java.net.Inet6Address +import java.net.NetworkInterface +import java.util.stream.Collectors + +class InteropTestParams( + val transport: String?, + val muxer: String?, + val security: String?, + val isDialer: Boolean, + val ip: String?, + val redisAddress: String?, + val testTimeoutInSeconds: Int +) { + + data class Builder( + var transport: String? = "", + var muxer: String? = "", + var security: String? = "", + var isDialer: Boolean = false, + var ip: String? = "", + var redisAddress: String? = "", + var testTimeoutInSeconds: Int = 180 + ) { + fun transport(transport: String) = apply { this.transport = transport } + fun muxer(muxer: String) = apply { this.muxer = muxer } + fun security(security: String) = apply { this.security = security } + fun isDialer(isDialer: Boolean) = apply { this.isDialer = isDialer } + fun ip(ip: String) = apply { this.ip = ip } + fun redisAddress(redisAddress: String) = apply { this.redisAddress = redisAddress } + fun testTimeoutInSeconds(testTimeoutInSeconds: Int) = + apply { this.testTimeoutInSeconds = testTimeoutInSeconds } + + fun build(): InteropTestParams { + checkNonEmptyParam("transport", transport) + checkNonEmptyParam("muxer", muxer) + checkNonEmptyParam("security", security) + checkNonEmptyParam("redis_addr", security) + + if (ip == null || ip!!.isBlank()) { + ip = "0.0.0.0" + } + if (!isDialer && ip.equals("0.0.0.0")) { + ip = getLocalIPAddress() + } + + return InteropTestParams( + transport, + muxer, + security, + isDialer, + ip, + redisAddress, + testTimeoutInSeconds + ) + } + + private fun checkNonEmptyParam(paramName: String, paramValue: String?) { + if (paramValue == null) { + throw IllegalArgumentException("Parameter '$paramName' must be non-empty") + } + } + + fun fromEnvironmentVariables(): Builder { + return Builder( + transport = System.getenv("transport"), + muxer = System.getenv("muxer"), + security = System.getenv("security"), + isDialer = System.getenv("is_dialer")?.toBooleanStrictOrNull() ?: false, + ip = System.getenv("ip"), + redisAddress = System.getenv("redis_addr"), + testTimeoutInSeconds = System.getenv("test_timeout_seconds")?.toInt() ?: 180 + ) + } + + private fun getLocalIPAddress(): String { + val interfaces = + NetworkInterface.networkInterfaces().collect(Collectors.toList()) + for (inter in interfaces) { + for (addr in inter.interfaceAddresses) { + val address = addr.address + if (!address.isLoopbackAddress && address !is Inet6Address) return address.hostAddress + } + } + throw IllegalStateException("Unable to determine local IPAddress") + } + } + + override fun toString(): String { + return "InteropTestParams(transport=$transport, muxer=$muxer, security=$security, isDialer=$isDialer, ip=$ip, redisAddress=$redisAddress, testTimeoutInSeconds=$testTimeoutInSeconds)" + } +} diff --git a/interop-test-client/src/test/resources/compose.yaml b/interop-test-client/src/test/resources/compose.yaml new file mode 100644 index 000000000..cf3ac37ef --- /dev/null +++ b/interop-test-client/src/test/resources/compose.yaml @@ -0,0 +1,30 @@ +# Compose file used for testing locally +services: + listener: + build: . + environment: + transport: "tcp" + muxer: "mplex" + security: "tls" + is_dialer: false + redis_addr: "redis:6379" + test_timeout_seconds: 180 + depends_on: + redis: + condition: service_started + dialer: + build: . + environment: + transport: "tcp" + muxer: "mplex" + security: "tls" + is_dialer: true + redis_addr: "redis:6379" + test_timeout_seconds: 180 + depends_on: + redis: + condition: service_started + redis: + image: "redis:7-alpine" + ports: + - "6379:6379" \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 3b2d71fae..158428ac4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,6 +23,7 @@ include ':tools:simulator' include ':examples:chatter' include ':examples:cli-chatter' include ':examples:pinger' +include 'interop-test-client' def getAndroidSdkDir() { def localPropertiesSdkDir = null From 3d4b05fa1315e9af90a50d1d3204d0717d7f90ae Mon Sep 17 00:00:00 2001 From: Enrico Del Fante Date: Wed, 27 Aug 2025 10:33:37 +0200 Subject: [PATCH 06/30] [QUIC] Set muxer session and secure session on connection (#413) --- .../io/libp2p/transport/quic/QuicTransport.kt | 124 ++++++++++++------ 1 file changed, 81 insertions(+), 43 deletions(-) diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt index 2574e4fc9..63d448de3 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt @@ -33,6 +33,7 @@ import io.netty.channel.nio.NioEventLoopGroup import io.netty.channel.socket.nio.NioDatagramChannel import io.netty.handler.ssl.ClientAuth import io.netty.incubator.codec.quic.* +import org.slf4j.LoggerFactory import java.net.* import java.time.Duration import java.util.* @@ -44,6 +45,7 @@ class QuicTransport( private val certAlgorithm: String, private val protocols: List> ) : NettyTransport { + private val log = LoggerFactory.getLogger(QuicTransport::class.java) private var closed = false var connectTimeout = Duration.ofSeconds(15) @@ -162,7 +164,7 @@ class QuicTransport( listeners -= addr } } - println("Quic server listening on " + addr) + log.info("Quic server listening on {}", addr) res.complete(null) } } @@ -220,29 +222,7 @@ class QuicTransport( connFuture.also { registerChannel(it.get()) val connection = ConnectionOverNetty(it.get(), this, true) - connection.setMuxerSession(object : StreamMuxer.Session { - override fun createStream(protocols: List>): StreamPromise { - var multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 - var streamMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } - val multi = streamMultistreamProtocol.createMultistream(protocols) - - val controller = CompletableFuture() - val streamFut = CompletableFuture() - it.get().createStream( - QuicStreamType.BIDIRECTIONAL, - object : ChannelInboundHandlerAdapter() { - override fun handlerAdded(ctx: ChannelHandlerContext?) { - val stream = createStream(ctx!!.channel(), connection, true) - ctx.channel().attr(STREAM).set(stream) - val streamHandler = multi.toStreamHandler() - streamHandler.handleStream(stream).forward(controller) - .apply { streamFut.complete(stream) } - } - } - ) - return StreamPromise(streamFut, controller) - } - }) + connection.setMuxerSession(QuicMuxerSession(it.get(), connection)) val pubHash = Multihash.of(addr.getPeerId()!!.bytes.toByteBuf()) val remotePubKey = if (pubHash.desc.digest == Multihash.Digest.Identity) { unmarshalPublicKey(pubHash.bytes.toByteArray()) @@ -305,7 +285,7 @@ class QuicTransport( val javaPrivateKey = getJavaKey(connectionKeys.first) val isClient = expectedRemotePeerId != null val cert = buildCert(localKey, connectionKeys.first) - println("Building " + certAlgorithm + " keys and cert for peerid " + PeerId.fromPubKey(localKey.publicKey())) + log.info("Building {} keys and cert for peerid {}", certAlgorithm, PeerId.fromPubKey(localKey.publicKey())) return ( if (isClient) { QuicSslContextBuilder.forClient().keyManager(javaPrivateKey, null, cert) @@ -313,21 +293,6 @@ class QuicTransport( QuicSslContextBuilder.forServer(javaPrivateKey, null, cert).clientAuth(ClientAuth.REQUIRE) } ) -// .option(BoringSSLContextOption.GROUPS, arrayOf("x25519")) -// .option( -// BoringSSLContextOption.SIGNATURE_ALGORITHMS, -// arrayOf( -// // "ed25519", -// "ecdsa_secp256r1_sha256", -// "rsa_pkcs1_sha256", -// "rsa_pss_rsae_sha256", -// "ecdsa_secp384r1_sha384", -// "rsa_pkcs1_sha384", -// "rsa_pss_rsae_sha384", -// "rsa_pss_rsae_sha512", -// "rsa_pkcs1_sha512", -// ) -// ) .trustManager(trustManager) .applicationProtocols("libp2p") .build() @@ -337,7 +302,8 @@ class QuicTransport( connHandler: ConnectionHandler, preHandler: ChannelVisitor? ): ChannelHandler { - val sslContext = quicSslContext(null, Libp2pTrustManager(Optional.empty())) + val trustManager = Libp2pTrustManager(Optional.empty()) + val sslContext = quicSslContext(null, trustManager) return QuicServerCodecBuilder() .sslEngineProvider({ q -> sslContext.newEngine(q.alloc()) }) .maxIdleTimeout(5000, TimeUnit.MILLISECONDS) @@ -346,9 +312,53 @@ class QuicTransport( .handler(object : ChannelInitializer() { override fun initChannel(ch: Channel) { val connection = ConnectionOverNetty(ch, this@QuicTransport, false) + connection.setMuxerSession(QuicMuxerSession(ch as QuicChannel, connection)) ch.attr(CONNECTION).set(connection) - preHandler?.also { it.visit(connection) } - connHandler.handleConnection(connection) + + // Add a handler to wait for channel activation (handshake completion) + ch.pipeline().addFirst( + "quic-handshake-waiter", + object : ChannelInboundHandlerAdapter() { + override fun channelActive(ctx: ChannelHandlerContext) { + // Now the handshake is complete and remoteCert should be available + val remoteCert = trustManager.remoteCert + if (remoteCert != null) { + val remotePeerId = verifyAndExtractPeerId(arrayOf(remoteCert)) + val remotePublicKey = getPublicKeyFromCert(arrayOf(remoteCert)) + + log.info("Handshake completed with remote peer id: {}", remotePeerId) + + connection.setSecureSession( + SecureChannel.Session( + PeerId.fromPubKey(localKey.publicKey()), + remotePeerId, + remotePublicKey, + null + ) + ) + + // Remove this handler as it's no longer needed + ctx.pipeline().remove(this) + + // Now it's safe to call the connection handler + preHandler?.also { it.visit(connection) } + connHandler.handleConnection(connection) + } else { + // This should not happen if channelActive is called after handshake + ctx.close() + throw IllegalStateException("Remote certificate still not available after handshake") + } + + super.channelActive(ctx) + } + + @Deprecated("Deprecated in Java") + override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { + log.error("An error during handshake", cause) + ctx.close() + } + } + ) } }) .initialMaxData(1024) @@ -359,6 +369,34 @@ class QuicTransport( .build() } + class QuicMuxerSession( + val ch: QuicChannel, + val connection: ConnectionOverNetty + ) : StreamMuxer.Session { + override fun createStream(protocols: List>): StreamPromise { + var multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 + var streamMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } + val multi = streamMultistreamProtocol.createMultistream(protocols) + + val controller = CompletableFuture() + val streamFut = CompletableFuture() + + ch.createStream( + QuicStreamType.BIDIRECTIONAL, + object : ChannelInboundHandlerAdapter() { + override fun handlerAdded(ctx: ChannelHandlerContext?) { + val stream = createStream(ctx!!.channel(), connection, true) + ctx.channel().attr(STREAM).set(stream) + val streamHandler = multi.toStreamHandler() + streamHandler.handleStream(stream).forward(controller) + .apply { streamFut.complete(stream) } + } + } + ) + return StreamPromise(streamFut, controller) + } + } + class InboundStreamHandler( val handler: MultistreamProtocol, val protocols: List> From 33ffc1ac03b7c69df995a7316b1bf0d116f4c8eb Mon Sep 17 00:00:00 2001 From: Stefan Bratanov Date: Thu, 28 Aug 2025 13:35:26 +0100 Subject: [PATCH 07/30] Use netty core instead of incubator artifact for QUIC (#412) --- examples/android-chatter/build.gradle | 22 +++++----- .../io/libp2p/interop/InteropTestAgent.kt | 3 +- libp2p/build.gradle.kts | 23 +++++----- .../kotlin/io/libp2p/etc/types/NettyExt.kt | 13 ++++++ .../implementation/PlainNettyTransport.kt | 20 +++++---- .../io/libp2p/transport/quic/QuicTransport.kt | 42 ++++++++++--------- .../ws/WebSocketClientInitializer.kt | 4 +- .../ws/WebSocketServerInitializer.kt | 4 +- .../transport/quic/QuicKuboTestJava.java | 2 +- .../transport/quic/QuicServerTestJava.java | 16 +++---- .../kotlin/io/libp2p/tools/TCPProxy.kt | 8 ++-- versions.gradle | 6 ++- 12 files changed, 95 insertions(+), 68 deletions(-) diff --git a/examples/android-chatter/build.gradle b/examples/android-chatter/build.gradle index f354ded48..fa37a35bd 100644 --- a/examples/android-chatter/build.gradle +++ b/examples/android-chatter/build.gradle @@ -22,17 +22,17 @@ android { } } packagingOptions { - exclude 'META-INF/io.netty.versions.properties' - exclude 'META-INF/INDEX.LIST' - exclude 'META-INF/versions/9/OSGI-INF/MANIFEST.MF' - exclude 'META-INF/license/LICENSE.aix-netbsd.txt' - exclude 'META-INF/native-image/io.netty.incubator/netty-incubator-codec-native-quic/jni-config.json' - exclude 'META-INF/native-image/io.netty.incubator/netty-incubator-codec-native-quic/reflect-config.json' - exclude 'META-INF/native-image/io.netty.incubator/netty-incubator-codec-native-quic/resource-config.json' - exclude 'META-INF/license/LICENSE.boringssl.txt' - exclude 'META-INF/license/LICENSE.mvn-wrapper.txt' - exclude 'META-INF/license/LICENSE.quiche.txt' - exclude 'META-INF/license/LICENSE.tomcat-native.txt' + resources { + excludes.add("META-INF/io.netty.versions.properties") + excludes.add("META-INF/INDEX.LIST") + excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") + excludes.add("META-INF/native-image/io.netty/netty-codec-native-quic/jni-config.json") + excludes.add("META-INF/native-image/io.netty/netty-codec-native-quic/reflect-config.json") + excludes.add("META-INF/native-image/io.netty/netty-codec-native-quic/resource-config.json") + excludes.add("META-INF/native-image/io.netty/netty-codec-native-quic/native-image.properties") + excludes.add("META-INF/license/*") + } + } kotlinOptions { jvmTarget = "11" diff --git a/interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt b/interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt index 45ec9a9d5..831e3b94c 100644 --- a/interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt +++ b/interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt @@ -19,6 +19,7 @@ import io.libp2p.protocol.Identify import io.libp2p.protocol.Ping import io.libp2p.security.noise.NoiseXXSecureChannel import io.libp2p.security.tls.TlsSecureChannel.Companion.ECDSA +import io.libp2p.transport.quic.QuicTransport import io.libp2p.transport.tcp.TcpTransport import redis.clients.jedis.Jedis import java.util.concurrent.CompletableFuture @@ -72,7 +73,7 @@ class InteropTestAgent(val params: InteropTestParams) { ): Host = hostJ(Builder.Defaults.None, fn = { it.identity.factory = { privateKey } if (params.transport == "quic-v1") { - // TODO add quic support + it.secureTransports.add(QuicTransport::ECDSA) } else { it.transports.add(::TcpTransport) } diff --git a/libp2p/build.gradle.kts b/libp2p/build.gradle.kts index 44b9a74b2..26fa61f56 100644 --- a/libp2p/build.gradle.kts +++ b/libp2p/build.gradle.kts @@ -14,19 +14,20 @@ dependencies { api("io.netty:netty-transport") implementation("io.netty:netty-handler") implementation("io.netty:netty-codec-http") + implementation("io.netty:netty-codec-protobuf") implementation("io.netty:netty-transport-classes-epoll") - implementation("io.netty.incubator:netty-incubator-codec-native-quic") + implementation("io.netty:netty-codec-native-quic") // OS-specific bindings - implementation("io.netty.incubator:netty-incubator-codec-native-quic::linux-x86_64") - implementation("io.netty.incubator:netty-incubator-codec-native-quic::linux-aarch_64") - implementation("io.netty.incubator:netty-incubator-codec-native-quic::osx-x86_64") - implementation("io.netty.incubator:netty-incubator-codec-native-quic::osx-aarch_64") - implementation("io.netty.incubator:netty-incubator-codec-native-quic::windows-x86_64") - implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:linux-x86_64") - implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:linux-aarch_64") - implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:osx-x86_64") - implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:osx-aarch_64") - implementation("io.netty:netty-tcnative-boringssl-static:2.0.70.Final:windows-x86_64") + implementation("io.netty:netty-codec-native-quic::linux-x86_64") + implementation("io.netty:netty-codec-native-quic::linux-aarch_64") + implementation("io.netty:netty-codec-native-quic::osx-x86_64") + implementation("io.netty:netty-codec-native-quic::osx-aarch_64") + implementation("io.netty:netty-codec-native-quic::windows-x86_64") + implementation("io.netty:netty-tcnative-boringssl-static::linux-x86_64") + implementation("io.netty:netty-tcnative-boringssl-static::linux-aarch_64") + implementation("io.netty:netty-tcnative-boringssl-static::osx-x86_64") + implementation("io.netty:netty-tcnative-boringssl-static::osx-aarch_64") + implementation("io.netty:netty-tcnative-boringssl-static::windows-x86_64") api("com.google.protobuf:protobuf-java") diff --git a/libp2p/src/main/kotlin/io/libp2p/etc/types/NettyExt.kt b/libp2p/src/main/kotlin/io/libp2p/etc/types/NettyExt.kt index e4c9c1d49..bf46b2157 100644 --- a/libp2p/src/main/kotlin/io/libp2p/etc/types/NettyExt.kt +++ b/libp2p/src/main/kotlin/io/libp2p/etc/types/NettyExt.kt @@ -4,6 +4,7 @@ import io.netty.channel.Channel import io.netty.channel.ChannelFuture import io.netty.channel.ChannelHandler import io.netty.channel.ChannelPipeline +import io.netty.util.concurrent.Future import java.util.concurrent.CompletableFuture fun ChannelFuture.toVoidCompletableFuture(): CompletableFuture = toCompletableFuture().thenApply { } @@ -20,6 +21,18 @@ fun ChannelFuture.toCompletableFuture(): CompletableFuture { return ret } +fun Future<*>.toVoidCompletableFuture(): CompletableFuture { + val ret = CompletableFuture() + this.addListener { + if (it.isSuccess) { + ret.complete(Unit) + } else { + ret.completeExceptionally(it.cause()) + } + } + return ret +} + fun ChannelPipeline.replace(oldHandler: ChannelHandler, newHandlers: List>) { replace(oldHandler, newHandlers[0].first, newHandlers[0].second) for (i in 1 until newHandlers.size) { diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/PlainNettyTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/PlainNettyTransport.kt index 96931e4ec..8d135fa9e 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/PlainNettyTransport.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/PlainNettyTransport.kt @@ -19,7 +19,8 @@ import io.netty.bootstrap.ServerBootstrap import io.netty.channel.Channel import io.netty.channel.ChannelHandler import io.netty.channel.ChannelOption -import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.MultiThreadIoEventLoopGroup +import io.netty.channel.nio.NioIoHandler import io.netty.channel.socket.nio.NioServerSocketChannel import io.netty.channel.socket.nio.NioSocketChannel import java.net.InetSocketAddress @@ -39,8 +40,12 @@ abstract class PlainNettyTransport( private val listeners = mutableMapOf() private val channels = mutableListOf() - private var workerGroup by lazyVar { NioEventLoopGroup() } - private var bossGroup by lazyVar { NioEventLoopGroup(1) } + private var workerGroup by lazyVar { + MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()) + } + private var bossGroup by lazyVar { + MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()) + } private var client by lazyVar { Bootstrap().apply { @@ -85,10 +90,11 @@ abstract class PlainNettyTransport( val everythingThatNeedsToClose = unbindsCompleted.union(channelsClosed) val allClosed = CompletableFuture.allOf(*everythingThatNeedsToClose.toTypedArray()) - return allClosed.thenApply { - workerGroup.shutdownGracefully() - bossGroup.shutdownGracefully() - Unit + return allClosed.thenCompose { + CompletableFuture.allOf( + workerGroup.shutdownGracefully().toVoidCompletableFuture(), + bossGroup.shutdownGracefully().toVoidCompletableFuture() + ).thenApply { } } } // close diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt index 63d448de3..c4f1a39d0 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt @@ -19,7 +19,11 @@ import io.libp2p.etc.STREAM import io.libp2p.etc.types.* import io.libp2p.etc.util.MultiaddrUtils import io.libp2p.etc.util.netty.nettyInitializer -import io.libp2p.security.tls.* +import io.libp2p.security.tls.Libp2pTrustManager +import io.libp2p.security.tls.buildCert +import io.libp2p.security.tls.getJavaKey +import io.libp2p.security.tls.getPublicKeyFromCert +import io.libp2p.security.tls.verifyAndExtractPeerId import io.libp2p.transport.implementation.ConnectionOverNetty import io.libp2p.transport.implementation.NettyTransport import io.libp2p.transport.implementation.StreamOverNetty @@ -29,12 +33,13 @@ import io.netty.buffer.PooledByteBufAllocator import io.netty.channel.* import io.netty.channel.epoll.Epoll import io.netty.channel.epoll.EpollDatagramChannel -import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.nio.NioIoHandler import io.netty.channel.socket.nio.NioDatagramChannel +import io.netty.handler.codec.quic.* import io.netty.handler.ssl.ClientAuth -import io.netty.incubator.codec.quic.* import org.slf4j.LoggerFactory -import java.net.* +import java.net.InetSocketAddress +import java.net.SocketAddress import java.time.Duration import java.util.* import java.util.concurrent.CompletableFuture @@ -45,7 +50,8 @@ class QuicTransport( private val certAlgorithm: String, private val protocols: List> ) : NettyTransport { - private val log = LoggerFactory.getLogger(QuicTransport::class.java) + + private val logger = LoggerFactory.getLogger(QuicTransport::class.java) private var closed = false var connectTimeout = Duration.ofSeconds(15) @@ -53,8 +59,9 @@ class QuicTransport( private val listeners = mutableMapOf() private val channels = mutableListOf() - private var workerGroup by lazyVar { NioEventLoopGroup() } - private var bossGroup by lazyVar { NioEventLoopGroup(1) } + private var workerGroup by lazyVar { + MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()) + } private var allocator by lazyVar { PooledByteBufAllocator(true) } private var multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 private var incomingMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } @@ -78,7 +85,7 @@ class QuicTransport( } @JvmStatic - fun Ecdsa(k: PrivKey, p: List>): QuicTransport { + fun ECDSA(k: PrivKey, p: List>): QuicTransport { return QuicTransport(k, "ECDSA", p) } @@ -128,10 +135,8 @@ class QuicTransport( val everythingThatNeedsToClose = unbindsCompleted.union(channelsClosed) val allClosed = CompletableFuture.allOf(*everythingThatNeedsToClose.toTypedArray()) - return allClosed.thenApply { - workerGroup.shutdownGracefully() - bossGroup.shutdownGracefully() - Unit + return allClosed.thenCompose { + workerGroup.shutdownGracefully().toVoidCompletableFuture() } } @@ -164,7 +169,7 @@ class QuicTransport( listeners -= addr } } - log.info("Quic server listening on {}", addr) + logger.info("Quic server listening on {}", addr) res.complete(null) } } @@ -208,7 +213,6 @@ class QuicTransport( .option(ChannelOption.AUTO_READ, true) .option(ChannelOption.ALLOCATOR, allocator) .remoteAddress(fromMultiaddr(addr)) -// .handler(connHandler) .streamHandler(object : ChannelInboundHandlerAdapter() { override fun handlerAdded(ctx: ChannelHandlerContext?) { val connection = ctx!!.channel().parent().attr(CONNECTION).get() as Connection @@ -285,7 +289,7 @@ class QuicTransport( val javaPrivateKey = getJavaKey(connectionKeys.first) val isClient = expectedRemotePeerId != null val cert = buildCert(localKey, connectionKeys.first) - log.info("Building {} keys and cert for peerid {}", certAlgorithm, PeerId.fromPubKey(localKey.publicKey())) + logger.info("Building {} keys and cert for peer id {}", certAlgorithm, PeerId.fromPubKey(localKey.publicKey())) return ( if (isClient) { QuicSslContextBuilder.forClient().keyManager(javaPrivateKey, null, cert) @@ -326,7 +330,7 @@ class QuicTransport( val remotePeerId = verifyAndExtractPeerId(arrayOf(remoteCert)) val remotePublicKey = getPublicKeyFromCert(arrayOf(remoteCert)) - log.info("Handshake completed with remote peer id: {}", remotePeerId) + logger.info("Handshake completed with remote peer id: {}", remotePeerId) connection.setSecureSession( SecureChannel.Session( @@ -354,7 +358,7 @@ class QuicTransport( @Deprecated("Deprecated in Java") override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { - log.error("An error during handshake", cause) + logger.error("An error during handshake", cause) ctx.close() } } @@ -374,8 +378,8 @@ class QuicTransport( val connection: ConnectionOverNetty ) : StreamMuxer.Session { override fun createStream(protocols: List>): StreamPromise { - var multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 - var streamMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } + val multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 + val streamMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } val multi = streamMultistreamProtocol.createMultistream(protocols) val controller = CompletableFuture() diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/ws/WebSocketClientInitializer.kt b/libp2p/src/main/kotlin/io/libp2p/transport/ws/WebSocketClientInitializer.kt index 1fde9d5c4..d38db14ae 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/ws/WebSocketClientInitializer.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/ws/WebSocketClientInitializer.kt @@ -12,12 +12,12 @@ internal class WebSocketClientInitializer( private val url: String ) : ChannelInitializer() { - public override fun initChannel(ch: SocketChannel) { + override fun initChannel(ch: SocketChannel) { val pipeline = ch.pipeline() pipeline.addLast(HttpClientCodec()) pipeline.addLast(HttpObjectAggregator(65536)) - pipeline.addLast(WebSocketClientCompressionHandler.INSTANCE) + pipeline.addLast(WebSocketClientCompressionHandler(0)) pipeline.addLast( WebSocketClientHandshake( connectionBuilder, diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/ws/WebSocketServerInitializer.kt b/libp2p/src/main/kotlin/io/libp2p/transport/ws/WebSocketServerInitializer.kt index f1a195ef4..0665aad98 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/ws/WebSocketServerInitializer.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/ws/WebSocketServerInitializer.kt @@ -12,12 +12,12 @@ internal class WebSocketServerInitializer( private val connectionBuilder: ChannelHandler ) : ChannelInitializer() { - public override fun initChannel(ch: SocketChannel) { + override fun initChannel(ch: SocketChannel) { val pipeline = ch.pipeline() pipeline.addLast(HttpServerCodec()) pipeline.addLast(HttpObjectAggregator(65536)) - pipeline.addLast(WebSocketServerCompressionHandler()) + pipeline.addLast(WebSocketServerCompressionHandler(0)) pipeline.addLast(WebSocketServerProtocolHandler("/", null, true)) pipeline.addLast(WebSocketServerHandshakeListener(connectionBuilder)) } // initChannel diff --git a/libp2p/src/test/java/io/libp2p/transport/quic/QuicKuboTestJava.java b/libp2p/src/test/java/io/libp2p/transport/quic/QuicKuboTestJava.java index bb408db73..c597eb8ba 100644 --- a/libp2p/src/test/java/io/libp2p/transport/quic/QuicKuboTestJava.java +++ b/libp2p/src/test/java/io/libp2p/transport/quic/QuicKuboTestJava.java @@ -21,7 +21,7 @@ void pingKubo() throws Exception { PeerId peerId = PeerId.fromBase58(getKuboPeerId()); Host clientHost = - new HostBuilder().keyType(KeyType.ED25519).secureTransport(QuicTransport::Ecdsa).build(); + new HostBuilder().keyType(KeyType.ED25519).secureTransport(QuicTransport::ECDSA).build(); CompletableFuture clientStarted = clientHost.start(); clientStarted.get(5, TimeUnit.SECONDS); diff --git a/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java b/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java index 0fb95b198..37f72be99 100644 --- a/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java +++ b/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java @@ -32,7 +32,7 @@ void pingJava() throws Exception { Host clientHost = new HostBuilder() .keyType(KeyType.ED25519) - .secureTransport(QuicTransport::Ecdsa) + .secureTransport(QuicTransport::ECDSA) .transport(TcpTransport::new) .secureChannel(TlsSecureChannel::ECDSA) .muxer(StreamMuxerProtocol::getYamux) @@ -41,7 +41,7 @@ void pingJava() throws Exception { Host serverHost = new HostBuilder() .keyType(KeyType.ED25519) - .secureTransport(QuicTransport::Ecdsa) + .secureTransport(QuicTransport::ECDSA) .transport(TcpTransport::new) .secureChannel(TlsSecureChannel::ECDSA) .muxer(StreamMuxerProtocol::getYamux) @@ -105,7 +105,7 @@ void tlsAndQuicInSameHostPing() throws Exception { Host clientHost = new HostBuilder() .keyType(KeyType.ED25519) - .secureTransport(QuicTransport::Ecdsa) + .secureTransport(QuicTransport::ECDSA) .transport(TcpTransport::new) .secureChannel(TlsSecureChannel::ECDSA) .secureChannel(NoiseXXSecureChannel::new) @@ -115,7 +115,7 @@ void tlsAndQuicInSameHostPing() throws Exception { Host serverHost = new HostBuilder() .keyType(KeyType.ED25519) - .secureTransport(QuicTransport::Ecdsa) + .secureTransport(QuicTransport::ECDSA) .transport(TcpTransport::new) .secureChannel(TlsSecureChannel::ECDSA) .secureChannel(NoiseXXSecureChannel::new) @@ -201,7 +201,7 @@ void largeBlob() throws Exception { Host clientHost = new HostBuilder() .keyType(KeyType.ED25519) - .secureTransport(QuicTransport::Ecdsa) + .secureTransport(QuicTransport::ECDSA) .builderModifier( b -> b.getDebug().getMuxFramesHandler().addCompactLogger(LogLevel.ERROR, "client")) .build(); @@ -209,7 +209,7 @@ void largeBlob() throws Exception { Host serverHost = new HostBuilder() .keyType(KeyType.ED25519) - .secureTransport(QuicTransport::Ecdsa) + .secureTransport(QuicTransport::ECDSA) .protocol(new Blob(blobSize)) .listen(localListenAddress) .builderModifier( @@ -262,12 +262,12 @@ void startHostAddPing() throws Exception { String localListenAddress = "/ip4/127.0.0.1/udp/" + getPort() + "/quic-v1"; Host clientHost = - new HostBuilder().keyType(KeyType.ED25519).secureTransport(QuicTransport::Ecdsa).build(); + new HostBuilder().keyType(KeyType.ED25519).secureTransport(QuicTransport::ECDSA).build(); Host serverHost = new HostBuilder() .keyType(KeyType.ED25519) - .secureTransport(QuicTransport::Ecdsa) + .secureTransport(QuicTransport::ECDSA) .listen(localListenAddress) .build(); diff --git a/libp2p/src/testFixtures/kotlin/io/libp2p/tools/TCPProxy.kt b/libp2p/src/testFixtures/kotlin/io/libp2p/tools/TCPProxy.kt index eb77980d7..6cb5c2d7b 100644 --- a/libp2p/src/testFixtures/kotlin/io/libp2p/tools/TCPProxy.kt +++ b/libp2p/src/testFixtures/kotlin/io/libp2p/tools/TCPProxy.kt @@ -7,7 +7,8 @@ import io.netty.channel.ChannelFuture import io.netty.channel.ChannelHandlerContext import io.netty.channel.ChannelInboundHandlerAdapter import io.netty.channel.ChannelOption -import io.netty.channel.nio.NioEventLoopGroup +import io.netty.channel.MultiThreadIoEventLoopGroup +import io.netty.channel.nio.NioIoHandler import io.netty.channel.socket.nio.NioServerSocketChannel import io.netty.channel.socket.nio.NioSocketChannel import io.netty.handler.logging.LogLevel @@ -19,7 +20,7 @@ class TCPProxy { fun start(listenPort: Int, dialHost: String, dialPort: Int): ChannelFuture { val future = ServerBootstrap().apply { - group(NioEventLoopGroup()) + group(MultiThreadIoEventLoopGroup(NioIoHandler.newFactory())) channel(NioServerSocketChannel::class.java) childHandler( nettyInitializer { @@ -29,7 +30,7 @@ class TCPProxy { serverCtx.channel().pipeline().addFirst(LoggingHandler("server", LogLevel.INFO)) Bootstrap().apply { - group(NioEventLoopGroup()) + group(MultiThreadIoEventLoopGroup(NioIoHandler.newFactory())) channel(NioSocketChannel::class.java) option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5 * 1000) handler(object : ChannelInboundHandlerAdapter() { @@ -38,7 +39,6 @@ class TCPProxy { } override fun channelActive(ctx: ChannelHandlerContext) { -// serverCtx.channel().pipeline().addFirst(LoggingHandler("client", LogLevel.INFO)) client.complete(ctx) } diff --git a/versions.gradle b/versions.gradle index 8e89ed8b6..d6f80d536 100644 --- a/versions.gradle +++ b/versions.gradle @@ -31,14 +31,17 @@ dependencyManagement { entry 'protobuf-java' entry 'protoc' } - dependencySet(group: "io.netty", version: "4.1.118.Final") { + dependencySet(group: "io.netty", version: "4.2.4.Final") { entry 'netty-common' entry 'netty-handler' entry 'netty-transport' entry 'netty-buffer' entry 'netty-codec-http' + entry 'netty-codec-protobuf' + entry 'netty-codec-native-quic' entry 'netty-transport-classes-epoll' } + dependency "io.netty:netty-tcnative-boringssl-static:2.0.72.Final" dependency "com.github.multiformats:java-multibase:v1.1.1" dependency "tech.pegasys:noise-java:22.1.0" dependencySet(group: "org.bouncycastle", version: "1.78.1") { @@ -46,6 +49,5 @@ dependencyManagement { entry 'bcpkix-jdk18on' entry 'bctls-jdk18on' } - dependency "io.netty.incubator:netty-incubator-codec-native-quic:0.0.71.Final" } } \ No newline at end of file From 25dd797a57715ab32ac4b3431bc98a997383f2f4 Mon Sep 17 00:00:00 2001 From: Ryu Seowoong Date: Fri, 29 Aug 2025 01:35:47 +0900 Subject: [PATCH 08/30] Apply InboundTrafficLimitHandler only if max bytes > 0 (#416) --- .../io/libp2p/protocol/circuit/CircuitHopProtocol.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libp2p/src/main/java/io/libp2p/protocol/circuit/CircuitHopProtocol.java b/libp2p/src/main/java/io/libp2p/protocol/circuit/CircuitHopProtocol.java index be2be179d..f0e4957f9 100644 --- a/libp2p/src/main/java/io/libp2p/protocol/circuit/CircuitHopProtocol.java +++ b/libp2p/src/main/java/io/libp2p/protocol/circuit/CircuitHopProtocol.java @@ -315,11 +315,15 @@ public void onMessage(@NotNull Stream stream, Circuit.HopMessage msg) { new CircuitStopProtocol.StopRemover()); // connect these streams with time + bytes enforcement - fromRequestor.pushHandler(new InboundTrafficLimitHandler(resv.maxBytes)); + if (resv.maxBytes > 0) { + fromRequestor.pushHandler(new InboundTrafficLimitHandler(resv.maxBytes)); + } fromRequestor.pushHandler( new TotalTimeoutHandler( Duration.of(resv.durationSeconds, ChronoUnit.SECONDS))); - toTarget.pushHandler(new InboundTrafficLimitHandler(resv.maxBytes)); + if (resv.maxBytes > 0) { + toTarget.pushHandler(new InboundTrafficLimitHandler(resv.maxBytes)); + } toTarget.pushHandler( new TotalTimeoutHandler( Duration.of(resv.durationSeconds, ChronoUnit.SECONDS))); From 63d74d0166e9f64cb49129e154c60e69d1cc557c Mon Sep 17 00:00:00 2001 From: Ryu Seowoong Date: Fri, 29 Aug 2025 16:34:11 +0900 Subject: [PATCH 09/30] Apply TotalTimeoutHandler only if duration seconds > 0 (#415) --- .../protocol/circuit/CircuitHopProtocol.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/libp2p/src/main/java/io/libp2p/protocol/circuit/CircuitHopProtocol.java b/libp2p/src/main/java/io/libp2p/protocol/circuit/CircuitHopProtocol.java index f0e4957f9..687880c15 100644 --- a/libp2p/src/main/java/io/libp2p/protocol/circuit/CircuitHopProtocol.java +++ b/libp2p/src/main/java/io/libp2p/protocol/circuit/CircuitHopProtocol.java @@ -318,15 +318,19 @@ public void onMessage(@NotNull Stream stream, Circuit.HopMessage msg) { if (resv.maxBytes > 0) { fromRequestor.pushHandler(new InboundTrafficLimitHandler(resv.maxBytes)); } - fromRequestor.pushHandler( - new TotalTimeoutHandler( - Duration.of(resv.durationSeconds, ChronoUnit.SECONDS))); + if (resv.durationSeconds > 0) { + fromRequestor.pushHandler( + new TotalTimeoutHandler( + Duration.of(resv.durationSeconds, ChronoUnit.SECONDS))); + } if (resv.maxBytes > 0) { toTarget.pushHandler(new InboundTrafficLimitHandler(resv.maxBytes)); } - toTarget.pushHandler( - new TotalTimeoutHandler( - Duration.of(resv.durationSeconds, ChronoUnit.SECONDS))); + if (resv.durationSeconds > 0) { + toTarget.pushHandler( + new TotalTimeoutHandler( + Duration.of(resv.durationSeconds, ChronoUnit.SECONDS))); + } fromRequestor.pushHandler(new ProxyHandler(toTarget)); toTarget.pushHandler(new ProxyHandler(fromRequestor)); } else { From 1419d27e87951ec7d94fd6baa62a97ffd83b5ad8 Mon Sep 17 00:00:00 2001 From: operagxsasha Date: Fri, 29 Aug 2025 11:24:43 +0300 Subject: [PATCH 10/30] docs: add build path to README (#419) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b7ec0ae09..722d56558 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![](https://img.shields.io/badge/project-libp2p-yellow.svg?style=flat-square)](https://libp2p.io/) [![Gitter](https://img.shields.io/gitter/room/libp2p/jvm-libp2p.svg)](https://gitter.im/jvm-libp2p/community) [![](https://img.shields.io/badge/freenode-%23libp2p-yellow.svg?style=flat-square)](http://webchat.freenode.net/?channels=%23libp2p) -![Build Status](https://github.com/libp2p/jvm-libp2p/actions/workflows/build.yml/badge.svg?branch=master) +[![Build Status](https://github.com/libp2p/jvm-libp2p/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/libp2p/jvm-libp2p/actions/workflows/build.yml) [![Discourse posts](https://img.shields.io/discourse/https/discuss.libp2p.io/posts.svg)](https://discuss.libp2p.io) [Libp2p](https://libp2p.io/) implementation for the JVM, written in Kotlin 🔥 From 8aa477df9e1c9a452706a8f51a7eee10df78c924 Mon Sep 17 00:00:00 2001 From: Stefan Bratanov Date: Fri, 12 Sep 2025 11:30:48 +0100 Subject: [PATCH 11/30] =?UTF-8?q?Move=20QUIC=20implementation=20to=20proto?= =?UTF-8?q?type=20=F0=9F=8D=8B=20=20(#423)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Co-authored-by: Anton Nashatyrev --- README.md | 2 +- .../io/libp2p/interop/InteropTestAgent.kt | 0 .../io/libp2p/interop/InteropTestParams.kt | 0 .../kotlin/io/libp2p/core/dsl/Builders.kt | 5 + .../kotlin/io/libp2p/etc/types/NettyExt.kt | 11 +- .../io/libp2p/etc/util/netty/NettyUtil.kt | 10 +- .../libp2p/protocol/ProtocolMessageHandler.kt | 6 +- .../protocol/ProtocolMessageHandlerAdapter.kt | 11 +- .../libp2p/security/tls/TLSSecureChannel.kt | 2 +- .../implementation/StreamOverNetty.kt | 2 +- .../io/libp2p/transport/quic/QuicStream.kt | 22 ++ .../quic/QuicStreamReadCloseEventConverter.kt | 20 ++ .../io/libp2p/transport/quic/QuicTransport.kt | 227 +++++++++--------- .../transport/quic/QuicServerTestJava.java | 185 +++++++++++++- .../kotlin/io/libp2p/protocol/Blob.kt | 0 .../kotlin/io/libp2p/protocol/OneShotPing.kt | 113 +++++++++ versions.gradle | 4 +- 17 files changed, 472 insertions(+), 148 deletions(-) rename interop-test-client/src/main/{java => kotlin}/io/libp2p/interop/InteropTestAgent.kt (100%) rename interop-test-client/src/main/{java => kotlin}/io/libp2p/interop/InteropTestParams.kt (100%) create mode 100644 libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicStream.kt create mode 100644 libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicStreamReadCloseEventConverter.kt rename libp2p/src/{test => testFixtures}/kotlin/io/libp2p/protocol/Blob.kt (100%) create mode 100644 libp2p/src/testFixtures/kotlin/io/libp2p/protocol/OneShotPing.kt diff --git a/README.md b/README.md index 722d56558..36d4a165b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ List of components in the Libp2p spec and their JVM implementation status | | Component | Status | |--------------------------|-------------------------------------------------------------------------------------------------|:----------------:| | **Transport** | tcp | :green_apple: | -| | [quic](https://github.com/libp2p/specs/tree/master/quic) | :tomato: | +| | [quic](https://github.com/libp2p/specs/tree/master/quic) | :lemon: | | | websocket | :lemon: | | | [webtransport](https://github.com/libp2p/specs/tree/master/webtransport) | | | | [webrtc-browser-to-server](https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md) | | diff --git a/interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt similarity index 100% rename from interop-test-client/src/main/java/io/libp2p/interop/InteropTestAgent.kt rename to interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt diff --git a/interop-test-client/src/main/java/io/libp2p/interop/InteropTestParams.kt b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt similarity index 100% rename from interop-test-client/src/main/java/io/libp2p/interop/InteropTestParams.kt rename to interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt diff --git a/libp2p/src/main/kotlin/io/libp2p/core/dsl/Builders.kt b/libp2p/src/main/kotlin/io/libp2p/core/dsl/Builders.kt index 36da96b72..7f6e6d9c5 100644 --- a/libp2p/src/main/kotlin/io/libp2p/core/dsl/Builders.kt +++ b/libp2p/src/main/kotlin/io/libp2p/core/dsl/Builders.kt @@ -90,6 +90,11 @@ open class Builder { */ open fun transports(fn: TransportsBuilder.() -> Unit): Builder = apply { fn(transports) } + /** + * Manipulates the secure transports for this host. + */ + open fun secureTransports(fn: SecureTransportsBuilder.() -> Unit): Builder = apply { fn(secureTransports) } + /** * [AddressBook] implementation */ diff --git a/libp2p/src/main/kotlin/io/libp2p/etc/types/NettyExt.kt b/libp2p/src/main/kotlin/io/libp2p/etc/types/NettyExt.kt index bf46b2157..e22d68efc 100644 --- a/libp2p/src/main/kotlin/io/libp2p/etc/types/NettyExt.kt +++ b/libp2p/src/main/kotlin/io/libp2p/etc/types/NettyExt.kt @@ -21,11 +21,14 @@ fun ChannelFuture.toCompletableFuture(): CompletableFuture { return ret } -fun Future<*>.toVoidCompletableFuture(): CompletableFuture { - val ret = CompletableFuture() +fun Future<*>.toVoidCompletableFuture(): CompletableFuture = toCompletableFuture().thenApply { } + +fun Future.toCompletableFuture(): CompletableFuture { + val ret = CompletableFuture() this.addListener { if (it.isSuccess) { - ret.complete(Unit) + @Suppress("UNCHECKED_CAST") + ret.complete(it.get() as T) } else { ret.completeExceptionally(it.cause()) } @@ -45,5 +48,5 @@ fun ChannelPipeline.getHandlerName(handler: ChannelHandler) = ( ?: throw IllegalArgumentException("Handler $handler not found in pipeline $this") ) -fun ChannelPipeline.addAfter(handler: ChannelHandler, newHandlerName: String, newHandler: ChannelHandler) = +fun ChannelPipeline.addAfter(handler: ChannelHandler, newHandlerName: String, newHandler: ChannelHandler): ChannelPipeline = addAfter(getHandlerName(handler), newHandlerName, newHandler) diff --git a/libp2p/src/main/kotlin/io/libp2p/etc/util/netty/NettyUtil.kt b/libp2p/src/main/kotlin/io/libp2p/etc/util/netty/NettyUtil.kt index 4743c9e6e..05502daa5 100644 --- a/libp2p/src/main/kotlin/io/libp2p/etc/util/netty/NettyUtil.kt +++ b/libp2p/src/main/kotlin/io/libp2p/etc/util/netty/NettyUtil.kt @@ -1,13 +1,12 @@ package io.libp2p.etc.util.netty import io.libp2p.etc.types.addAfter -import io.libp2p.etc.types.fromHex import io.netty.channel.Channel import io.netty.channel.ChannelHandler import io.netty.channel.ChannelInitializer import io.netty.util.internal.StringUtil -class NettyInit(val channel: Channel, val thisHandler: ChannelHandler) { +class NettyInit(val channel: Channel, thisHandler: ChannelHandler) { private var lastLocalHandler = thisHandler fun addLastLocal(handler: ChannelHandler) { channel.pipeline().addAfter(lastLocalHandler, generateName(channel, handler), handler) @@ -23,13 +22,6 @@ fun nettyInitializer(initer: (NettyInit) -> Unit): ChannelInitializer { } } -private val regex = Regex("\\|[0-9a-fA-F]{8}\\| ") -fun String.fromLogHandler() = lines() - .filter { it.contains(regex) } - .map { it.substring(11, 59).replace(" ", "") } - .flatMap { it.fromHex().asList() } - .toByteArray() - private fun generateName(ch: Channel, handler: ChannelHandler): String { val className = StringUtil.simpleClassName(handler.javaClass) val names = ch.pipeline().names().toSet() diff --git a/libp2p/src/main/kotlin/io/libp2p/protocol/ProtocolMessageHandler.kt b/libp2p/src/main/kotlin/io/libp2p/protocol/ProtocolMessageHandler.kt index f59b8d84c..6fd3e2900 100644 --- a/libp2p/src/main/kotlin/io/libp2p/protocol/ProtocolMessageHandler.kt +++ b/libp2p/src/main/kotlin/io/libp2p/protocol/ProtocolMessageHandler.kt @@ -6,10 +6,6 @@ interface ProtocolMessageHandler { fun onActivated(stream: Stream) = Unit fun onMessage(stream: Stream, msg: TMessage) = Unit fun onClosed(stream: Stream) = Unit + fun onReadClosed(stream: Stream) = Unit fun onException(cause: Throwable?) = Unit - - fun fireMessage(stream: Stream, msg: Any) { - @Suppress("UNCHECKED_CAST") - onMessage(stream, msg as TMessage) - } } diff --git a/libp2p/src/main/kotlin/io/libp2p/protocol/ProtocolMessageHandlerAdapter.kt b/libp2p/src/main/kotlin/io/libp2p/protocol/ProtocolMessageHandlerAdapter.kt index a86dd2cce..4cf9218ec 100644 --- a/libp2p/src/main/kotlin/io/libp2p/protocol/ProtocolMessageHandlerAdapter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/protocol/ProtocolMessageHandlerAdapter.kt @@ -1,6 +1,7 @@ package io.libp2p.protocol import io.libp2p.core.Stream +import io.libp2p.etc.util.netty.mux.RemoteWriteClosed import io.netty.channel.ChannelHandlerContext import io.netty.channel.SimpleChannelInboundHandler import io.netty.util.ReferenceCounted @@ -33,7 +34,8 @@ class ProtocolMessageHandlerAdapter( } override fun channelRead0(ctx: ChannelHandlerContext?, msg: Any) { - pmh.fireMessage(stream, msg) + @Suppress("UNCHECKED_CAST") + pmh.onMessage(stream, msg as TMessage) } override fun channelUnregistered(ctx: ChannelHandlerContext?) { @@ -44,6 +46,13 @@ class ProtocolMessageHandlerAdapter( pmh.onException(cause) } + override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) { + if (evt == RemoteWriteClosed) { + pmh.onReadClosed(stream) + } + super.userEventTriggered(ctx, evt) + } + // /////////////////////// private fun refCount(obj: Any): Int { return if (obj is ReferenceCounted) { diff --git a/libp2p/src/main/kotlin/io/libp2p/security/tls/TLSSecureChannel.kt b/libp2p/src/main/kotlin/io/libp2p/security/tls/TLSSecureChannel.kt index acc6e4687..fb3c33ca0 100644 --- a/libp2p/src/main/kotlin/io/libp2p/security/tls/TLSSecureChannel.kt +++ b/libp2p/src/main/kotlin/io/libp2p/security/tls/TLSSecureChannel.kt @@ -104,7 +104,7 @@ fun buildTlsHandler( handshakeComplete: CompletableFuture, ctx: ChannelHandlerContext ): SslHandler { - val connectionKeys = if (certAlgorithm.equals("ECDSA")) generateEcdsaKeyPair() else generateEd25519KeyPair() + val connectionKeys = if (certAlgorithm == "ECDSA") generateEcdsaKeyPair() else generateEd25519KeyPair() val javaPrivateKey = getJavaKey(connectionKeys.first) val sslContext = ( if (isInitiator) { diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/StreamOverNetty.kt b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/StreamOverNetty.kt index b0324a7e7..73c550ecc 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/implementation/StreamOverNetty.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/implementation/StreamOverNetty.kt @@ -8,7 +8,7 @@ import io.libp2p.etc.types.toVoidCompletableFuture import io.netty.channel.Channel import java.util.concurrent.CompletableFuture -class StreamOverNetty( +open class StreamOverNetty( ch: Channel, override val connection: Connection, initiator: Boolean diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicStream.kt b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicStream.kt new file mode 100644 index 000000000..e2e662ff5 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicStream.kt @@ -0,0 +1,22 @@ +package io.libp2p.transport.quic + +import io.libp2p.core.Connection +import io.libp2p.etc.types.toVoidCompletableFuture +import io.libp2p.transport.implementation.StreamOverNetty +import io.netty.handler.codec.quic.QuicStreamChannel +import java.util.concurrent.CompletableFuture + +class QuicStream( + val quicStreamChannel: QuicStreamChannel, + connection: Connection, + initiator: Boolean +) : StreamOverNetty(quicStreamChannel, connection, initiator) { + + init { + pushHandler(QuicStreamReadCloseEventConverter()) + } + + override fun closeWrite(): CompletableFuture { + return quicStreamChannel.shutdownOutput().toVoidCompletableFuture() + } +} diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicStreamReadCloseEventConverter.kt b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicStreamReadCloseEventConverter.kt new file mode 100644 index 000000000..cde8a7781 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicStreamReadCloseEventConverter.kt @@ -0,0 +1,20 @@ +package io.libp2p.transport.quic + +import io.libp2p.etc.util.netty.mux.RemoteWriteClosed +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInboundHandlerAdapter +import io.netty.channel.socket.ChannelInputShutdownReadComplete + +/** + * Convert QUIC library specific event on remote stream close to Libp2p specific event + */ +class QuicStreamReadCloseEventConverter : ChannelInboundHandlerAdapter() { + + override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) { + if (evt == ChannelInputShutdownReadComplete.INSTANCE) { + ctx.fireUserEventTriggered(RemoteWriteClosed) + } else { + super.userEventTriggered(ctx, evt) + } + } +} diff --git a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt index c4f1a39d0..7ea3c0a14 100644 --- a/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt +++ b/libp2p/src/main/kotlin/io/libp2p/transport/quic/QuicTransport.kt @@ -26,10 +26,9 @@ import io.libp2p.security.tls.getPublicKeyFromCert import io.libp2p.security.tls.verifyAndExtractPeerId import io.libp2p.transport.implementation.ConnectionOverNetty import io.libp2p.transport.implementation.NettyTransport -import io.libp2p.transport.implementation.StreamOverNetty import io.netty.bootstrap.Bootstrap +import io.netty.buffer.AdaptiveByteBufAllocator import io.netty.buffer.ByteBuf -import io.netty.buffer.PooledByteBufAllocator import io.netty.channel.* import io.netty.channel.epoll.Epoll import io.netty.channel.epoll.EpollDatagramChannel @@ -43,7 +42,6 @@ import java.net.SocketAddress import java.time.Duration import java.util.* import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit class QuicTransport( private val localKey: PrivKey, @@ -54,7 +52,8 @@ class QuicTransport( private val logger = LoggerFactory.getLogger(QuicTransport::class.java) private var closed = false - var connectTimeout = Duration.ofSeconds(15) + + private val connectTimeout = Duration.ofSeconds(15) private val listeners = mutableMapOf() private val channels = mutableListOf() @@ -62,22 +61,10 @@ class QuicTransport( private var workerGroup by lazyVar { MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()) } - private var allocator by lazyVar { PooledByteBufAllocator(true) } + private var allocator by lazyVar { AdaptiveByteBufAllocator(true) } private var multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 private var incomingMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } - private var client by lazyVar { - Bootstrap().group(workerGroup) - .channel( - if (Epoll.isAvailable()) { - EpollDatagramChannel::class.java - } else { - NioDatagramChannel::class.java - } - ) - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout.toMillis().toInt()) - } - companion object { @JvmStatic fun Ed25519(k: PrivKey, p: List>): QuicTransport { @@ -89,13 +76,25 @@ class QuicTransport( return QuicTransport(k, "ECDSA", p) } - private fun createStream(channel: Channel, connection: Connection, initiator: Boolean): Stream { - val stream = StreamOverNetty(channel, connection, initiator) + private fun createStream(channel: QuicStreamChannel, connection: Connection, initiator: Boolean): Stream { + val stream = QuicStream(channel, connection, initiator) channel.attr(STREAM).set(stream) return stream } } + private var client by lazyVar { + Bootstrap().group(workerGroup) + .channel( + if (Epoll.isAvailable()) { + EpollDatagramChannel::class.java + } else { + NioDatagramChannel::class.java + } + ) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout.toMillis().toInt()) + } + private var server by lazyVar { Bootstrap().group(workerGroup) .channel( @@ -149,18 +148,16 @@ class QuicTransport( val channelHandler = serverTransportBuilder(connHandler, preHandler) - val bindComplete = server.clone() + val listener = server.clone() .handler( - nettyInitializer { init -> - registerChannel(init.channel) - init.addLastLocal(channelHandler) + nettyInitializer { + registerChannel(it.channel) + it.addLastLocal(channelHandler) } ) - .localAddress(fromMultiaddr(addr)) - .bind() - .sync() - val res = CompletableFuture() + val bindComplete = listener.bind(fromMultiaddr(addr)) + bindComplete.also { synchronized(this@QuicTransport) { listeners += addr to it.channel() @@ -169,12 +166,12 @@ class QuicTransport( listeners -= addr } } - logger.info("Quic server listening on {}", addr) - res.complete(null) } } - return res + return bindComplete.toVoidCompletableFuture().thenApply { + logger.info("Quic server listening on {}", addr) + } } override fun unlisten(addr: Multiaddr): CompletableFuture { @@ -189,63 +186,59 @@ class QuicTransport( ): CompletableFuture { if (closed) throw Libp2pException("Transport is closed") - val trust = Libp2pTrustManager(Optional.ofNullable(addr.getPeerId())) - val sslContext = quicSslContext(addr.getPeerId(), trust) - val handler = QuicClientCodecBuilder() - .sslEngineProvider({ q -> sslContext.newEngine(q.alloc()) }) - .maxIdleTimeout(15000, TimeUnit.MILLISECONDS) + val trustManager = Libp2pTrustManager(Optional.ofNullable(addr.getPeerId())) + val sslContext = quicSslContext(true, trustManager) + val requestsHandler = QuicClientCodecBuilder() + .sslEngineProvider { q -> sslContext.newEngine(q.alloc()) } .sslTaskExecutor(workerGroup) - .initialMaxData(1024) - .initialMaxStreamsBidirectional(16) - .initialMaxStreamDataBidirectionalRemote(1024) - .initialMaxStreamDataBidirectionalLocal(1024) + .initialMaxData(1 shl 20) + .initialMaxStreamsBidirectional(64) + .initialMaxStreamDataBidirectionalRemote(1 shl 18) + .initialMaxStreamDataBidirectionalLocal(1 shl 18) .build() - val connFuture = QuicChannel.newBootstrap( - client.clone() - .handler(handler) - .localAddress(0) - .bind() - .sync() - .channel() - ) - .streamOption(ChannelOption.ALLOCATOR, allocator) - .option(ChannelOption.AUTO_READ, true) - .option(ChannelOption.ALLOCATOR, allocator) - .remoteAddress(fromMultiaddr(addr)) - .streamHandler(object : ChannelInboundHandlerAdapter() { - override fun handlerAdded(ctx: ChannelHandlerContext?) { - val connection = ctx!!.channel().parent().attr(CONNECTION).get() as Connection - preHandler?.also { it.visit(connection) } - connHandler.handleConnection(connection) - } - }) - .connect() - - val res = CompletableFuture() - connFuture.also { - registerChannel(it.get()) - val connection = ConnectionOverNetty(it.get(), this, true) - connection.setMuxerSession(QuicMuxerSession(it.get(), connection)) - val pubHash = Multihash.of(addr.getPeerId()!!.bytes.toByteBuf()) - val remotePubKey = if (pubHash.desc.digest == Multihash.Digest.Identity) { - unmarshalPublicKey(pubHash.bytes.toByteArray()) - } else { - getPublicKeyFromCert(arrayOf(trust.remoteCert!!)) + return client.clone() + .handler(requestsHandler) + .bind(0) + .toCompletableFuture() + .thenCompose { + QuicChannel.newBootstrap(it) + .streamOption(ChannelOption.ALLOCATOR, allocator) + .option(ChannelOption.AUTO_READ, true) + .option(ChannelOption.ALLOCATOR, allocator) + .remoteAddress(fromMultiaddr(addr)) + .streamHandler(InboundStreamHandler(multistreamProtocol, protocols)) + .connect() + .toCompletableFuture() } - connection.setSecureSession( - SecureChannel.Session( - PeerId.fromPubKey(localKey.publicKey()), - addr.getPeerId()!!, - remotePubKey, - null + .thenApply { + registerChannel(it) + val connection = ConnectionOverNetty(it, this@QuicTransport, true) + + connection.setMuxerSession(QuicMuxerSession(it, connection)) + + val pubHash = Multihash.of(addr.getPeerId()!!.bytes.toByteBuf()) + val remotePubKey = if (pubHash.desc.digest == Multihash.Digest.Identity) { + unmarshalPublicKey(pubHash.bytes.toByteArray()) + } else { + getPublicKeyFromCert(arrayOf(trustManager.remoteCert!!)) + } + connection.setSecureSession( + SecureChannel.Session( + PeerId.fromPubKey(localKey.publicKey()), + addr.getPeerId()!!, + remotePubKey, + null + ) ) - ) - preHandler?.also { it.visit(connection) } - connHandler.handleConnection(connection) - res.complete(connection) - } - return res + + preHandler?.also { visitor -> visitor.visit(connection) } + connHandler.handleConnection(connection) + + it.attr(CONNECTION).set(connection) + + connection + } } private fun registerChannel(ch: Channel) { @@ -264,10 +257,10 @@ class QuicTransport( } } - protected fun handlesHost(addr: Multiaddr) = + private fun handlesHost(addr: Multiaddr) = addr.hasAny(IP4, IP6, DNS4, DNS6, DNSADDR) - protected fun hostFromMultiaddr(addr: Multiaddr): String { + private fun hostFromMultiaddr(addr: Multiaddr): String { val resolvedAddresses = MultiaddrDns.resolve(addr) if (resolvedAddresses.isEmpty()) { throw Libp2pException("Could not resolve $addr to an IP address") @@ -284,12 +277,11 @@ class QuicTransport( addr.has(QUICV1) && !addr.has(WS) - fun quicSslContext(expectedRemotePeerId: PeerId?, trustManager: Libp2pTrustManager): QuicSslContext { - val connectionKeys = if (certAlgorithm.equals("ECDSA")) generateEcdsaKeyPair() else generateEd25519KeyPair() + fun quicSslContext(isClient: Boolean, trustManager: Libp2pTrustManager): QuicSslContext { + val connectionKeys = if (certAlgorithm == "ECDSA") generateEcdsaKeyPair() else generateEd25519KeyPair() val javaPrivateKey = getJavaKey(connectionKeys.first) - val isClient = expectedRemotePeerId != null val cert = buildCert(localKey, connectionKeys.first) - logger.info("Building {} keys and cert for peer id {}", certAlgorithm, PeerId.fromPubKey(localKey.publicKey())) + logger.trace("Building {} keys and cert for peer id {}", certAlgorithm, PeerId.fromPubKey(localKey.publicKey())) return ( if (isClient) { QuicSslContextBuilder.forClient().keyManager(javaPrivateKey, null, cert) @@ -307,20 +299,20 @@ class QuicTransport( preHandler: ChannelVisitor? ): ChannelHandler { val trustManager = Libp2pTrustManager(Optional.empty()) - val sslContext = quicSslContext(null, trustManager) + val sslContext = quicSslContext(false, trustManager) return QuicServerCodecBuilder() - .sslEngineProvider({ q -> sslContext.newEngine(q.alloc()) }) - .maxIdleTimeout(5000, TimeUnit.MILLISECONDS) + .sslEngineProvider { q -> sslContext.newEngine(q.alloc()) } .sslTaskExecutor(workerGroup) .tokenHandler(NoTokenHandler()) - .handler(object : ChannelInitializer() { - override fun initChannel(ch: Channel) { - val connection = ConnectionOverNetty(ch, this@QuicTransport, false) - connection.setMuxerSession(QuicMuxerSession(ch as QuicChannel, connection)) - ch.attr(CONNECTION).set(connection) + .handler( + nettyInitializer { + val connection = ConnectionOverNetty(it.channel, this@QuicTransport, false) + + connection.setMuxerSession(QuicMuxerSession(it.channel as QuicChannel, connection)) + it.channel.attr(CONNECTION).set(connection) // Add a handler to wait for channel activation (handshake completion) - ch.pipeline().addFirst( + it.channel.pipeline().addFirst( "quic-handshake-waiter", object : ChannelInboundHandlerAdapter() { override fun channelActive(ctx: ChannelHandlerContext) { @@ -345,7 +337,7 @@ class QuicTransport( ctx.pipeline().remove(this) // Now it's safe to call the connection handler - preHandler?.also { it.visit(connection) } + preHandler?.also { visitor -> visitor.visit(connection) } connHandler.handleConnection(connection) } else { // This should not happen if channelActive is called after handshake @@ -364,11 +356,11 @@ class QuicTransport( } ) } - }) - .initialMaxData(1024) - .initialMaxStreamsBidirectional(16) - .initialMaxStreamDataBidirectionalRemote(1024) - .initialMaxStreamDataBidirectionalLocal(1024) + ) + .initialMaxData(1 shl 20) + .initialMaxStreamsBidirectional(64) + .initialMaxStreamDataBidirectionalRemote(1 shl 18) + .initialMaxStreamDataBidirectionalLocal(1 shl 18) .streamHandler(InboundStreamHandler(incomingMultistreamProtocol, protocols)) .build() } @@ -377,35 +369,36 @@ class QuicTransport( val ch: QuicChannel, val connection: ConnectionOverNetty ) : StreamMuxer.Session { + override fun createStream(protocols: List>): StreamPromise { val multistreamProtocol: MultistreamProtocol = MultistreamProtocolV1 val streamMultistreamProtocol: MultistreamProtocol by lazyVar { multistreamProtocol } val multi = streamMultistreamProtocol.createMultistream(protocols) val controller = CompletableFuture() - val streamFut = CompletableFuture() - ch.createStream( + val stream = ch.createStream( QuicStreamType.BIDIRECTIONAL, - object : ChannelInboundHandlerAdapter() { - override fun handlerAdded(ctx: ChannelHandlerContext?) { - val stream = createStream(ctx!!.channel(), connection, true) - ctx.channel().attr(STREAM).set(stream) - val streamHandler = multi.toStreamHandler() - streamHandler.handleStream(stream).forward(controller) - .apply { streamFut.complete(stream) } - } + nettyInitializer { + val stream = createStream(it.channel as QuicStreamChannel, connection, true) + val streamHandler = multi.toStreamHandler() + streamHandler.handleStream(stream).forward(controller) } - ) - return StreamPromise(streamFut, controller) + ).toCompletableFuture() + .thenApply { + it.attr(STREAM).get() + } + .forwardException(controller) + + return StreamPromise(stream, controller) } } class InboundStreamHandler( val handler: MultistreamProtocol, val protocols: List> - ) : ChannelInitializer() { - override fun initChannel(ch: Channel) { + ) : ChannelInitializer() { + override fun initChannel(ch: QuicStreamChannel) { val connection = ch.parent().attr(CONNECTION).get() val stream = createStream(ch, connection, false) val streamHandler = handler.createMultistream(protocols).toStreamHandler() diff --git a/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java b/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java index 37f72be99..da5f2966d 100644 --- a/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java +++ b/libp2p/src/test/java/io/libp2p/transport/quic/QuicServerTestJava.java @@ -1,24 +1,40 @@ package io.libp2p.transport.quic; +import io.libp2p.core.Connection; +import io.libp2p.core.ConnectionHandler; import io.libp2p.core.Host; import io.libp2p.core.PeerId; import io.libp2p.core.Stream; import io.libp2p.core.StreamPromise; -import io.libp2p.core.crypto.*; -import io.libp2p.core.dsl.*; -import io.libp2p.core.multiformats.*; +import io.libp2p.core.crypto.KeyKt; +import io.libp2p.core.crypto.KeyType; +import io.libp2p.core.crypto.PrivKey; +import io.libp2p.core.crypto.PubKey; +import io.libp2p.core.dsl.HostBuilder; +import io.libp2p.core.multiformats.Multiaddr; import io.libp2p.core.mux.StreamMuxerProtocol; -import io.libp2p.protocol.*; +import io.libp2p.protocol.Blob; +import io.libp2p.protocol.BlobController; +import io.libp2p.protocol.OneShotPing; +import io.libp2p.protocol.OneShotPingController; +import io.libp2p.protocol.Ping; +import io.libp2p.protocol.PingController; import io.libp2p.security.noise.NoiseXXSecureChannel; import io.libp2p.security.tls.TlsSecureChannel; import io.libp2p.transport.tcp.TcpTransport; import io.netty.handler.logging.LogLevel; import java.util.Random; import java.util.Set; -import java.util.concurrent.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; -import kotlin.*; -import org.junit.jupiter.api.*; +import kotlin.Pair; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; public class QuicServerTestJava { public static int getPort() { @@ -96,6 +112,161 @@ void pingJava() throws Exception { System.out.println("Server stopped"); } + @Test + void checkThatRemotePeerIdCorrectForSECP256K1() throws Exception { + String localListenAddress = "/ip4/127.0.0.1/udp/" + getPort() + "/quic-v1"; + + class TestConnectionHandler implements ConnectionHandler { + public final CompletableFuture remotePeerIdFuture = new CompletableFuture<>(); + + @Override + public void handleConnection(@NotNull Connection conn) { + remotePeerIdFuture.complete(conn.secureSession().getRemoteId()); + } + } + + TestConnectionHandler clientHandler = new TestConnectionHandler(); + TestConnectionHandler serverHandler = new TestConnectionHandler(); + + Host clientHost = + new HostBuilder() + .keyType(KeyType.SECP256K1) + .secureTransport(QuicTransport::ECDSA) + .builderModifier(b -> b.getConnectionHandlers().add(clientHandler)) + .build(); + + Host serverHost = + new HostBuilder() + .keyType(KeyType.SECP256K1) + .secureTransport(QuicTransport::ECDSA) + .transport(TcpTransport::new) + .listen(localListenAddress) + .builderModifier(b -> b.getConnectionHandlers().add(serverHandler)) + .build(); + + clientHost.start().get(5, TimeUnit.SECONDS); + serverHost.start().get(5, TimeUnit.SECONDS); + + clientHost.getNetwork().connect(serverHost.getPeerId(), new Multiaddr(localListenAddress)); + + Assertions.assertEquals( + serverHost.getPeerId(), clientHandler.remotePeerIdFuture.get(10, TimeUnit.SECONDS)); + Assertions.assertEquals( + clientHost.getPeerId(), serverHandler.remotePeerIdFuture.get(10, TimeUnit.SECONDS)); + + clientHost.stop().get(5, TimeUnit.SECONDS); + serverHost.stop().get(5, TimeUnit.SECONDS); + } + + @Disabled("Runs too long") + @Test + void checkConnectionIsNotClosedByTimeout() throws Exception { + String localListenAddress = "/ip4/127.0.0.1/udp/" + getPort() + "/quic-v1"; + + Host clientHost = + new HostBuilder().keyType(KeyType.SECP256K1).secureTransport(QuicTransport::ECDSA).build(); + + Host serverHost = + new HostBuilder() + .keyType(KeyType.SECP256K1) + .secureTransport(QuicTransport::ECDSA) + .transport(TcpTransport::new) + .listen(localListenAddress) + .build(); + + clientHost.start().get(5, TimeUnit.SECONDS); + serverHost.start().get(5, TimeUnit.SECONDS); + + Connection connection = + clientHost + .getNetwork() + .connect(serverHost.getPeerId(), new Multiaddr(localListenAddress)) + .get(10, TimeUnit.SECONDS); + + try { + long s = System.currentTimeMillis(); + connection.closeFuture().get(60, TimeUnit.SECONDS); + long t = System.currentTimeMillis() - s; + Assertions.fail("closeFuture complete in " + t + " ms"); + } catch (TimeoutException e) { + // expected exception: connection was not closed + } catch (Exception e) { + throw new RuntimeException("Unexpected exception", e); + } + + clientHost.stop().get(5, TimeUnit.SECONDS); + serverHost.stop().get(5, TimeUnit.SECONDS); + } + + @Test + void oneShotPingJava() throws Exception { + String localListenAddress = "/ip4/127.0.0.1/udp/" + getPort() + "/quic-v1"; + + Host clientHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::ECDSA) + .transport(TcpTransport::new) + .secureChannel(TlsSecureChannel::ECDSA) + .muxer(StreamMuxerProtocol::getYamux) + .build(); + + Host serverHost = + new HostBuilder() + .keyType(KeyType.ED25519) + .secureTransport(QuicTransport::ECDSA) + .transport(TcpTransport::new) + .secureChannel(TlsSecureChannel::ECDSA) + .muxer(StreamMuxerProtocol::getYamux) + .protocol(new OneShotPing()) + .listen(localListenAddress) + .build(); + + CompletableFuture clientStarted = clientHost.start(); + CompletableFuture serverStarted = serverHost.start(); + clientStarted.get(5, TimeUnit.SECONDS); + System.out.println("Client started " + clientHost.getPeerId()); + serverStarted.get(5, TimeUnit.SECONDS); + System.out.println("Server started " + serverHost.getPeerId()); + + Assertions.assertEquals(0, clientHost.listenAddresses().size()); + Assertions.assertEquals(1, serverHost.listenAddresses().size()); + Assertions.assertEquals( + localListenAddress + "/p2p/" + serverHost.getPeerId(), + serverHost.listenAddresses().get(0).toString()); + System.out.println("Hosts running"); + Thread.sleep(2_000); + + StreamPromise ping = + clientHost + .getNetwork() + .connect(serverHost.getPeerId(), new Multiaddr(localListenAddress)) + .thenApply(it -> it.muxerSession().createStream(new OneShotPing(500))) + .get(5000, TimeUnit.SECONDS); + + Stream pingStream = ping.getStream().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream created"); + CompletableFuture controller = ping.getController(); + OneShotPingController pingCtr = controller.get(5000, TimeUnit.SECONDS); + System.out.println("Ping controller created"); + pingStream.getConnection().localAddress(); + Multiaddr remote = pingStream.getConnection().remoteAddress(); + Assertions.assertEquals(localListenAddress, remote.toString()); + + long s = System.currentTimeMillis(); + pingCtr.ping().get(20, TimeUnit.SECONDS); + long l = System.currentTimeMillis() - s; + System.out.println("One Shot Ping is Done in " + l + " ms"); + + pingStream.close().get(5, TimeUnit.SECONDS); + System.out.println("Ping stream closed"); + + clientHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Client stopped"); + serverHost.stop().get(5, TimeUnit.SECONDS); + System.out.println("Server stopped"); + } + @Test void tlsAndQuicInSameHostPing() throws Exception { int port = getPort(); diff --git a/libp2p/src/test/kotlin/io/libp2p/protocol/Blob.kt b/libp2p/src/testFixtures/kotlin/io/libp2p/protocol/Blob.kt similarity index 100% rename from libp2p/src/test/kotlin/io/libp2p/protocol/Blob.kt rename to libp2p/src/testFixtures/kotlin/io/libp2p/protocol/Blob.kt diff --git a/libp2p/src/testFixtures/kotlin/io/libp2p/protocol/OneShotPing.kt b/libp2p/src/testFixtures/kotlin/io/libp2p/protocol/OneShotPing.kt new file mode 100644 index 000000000..1c85f912f --- /dev/null +++ b/libp2p/src/testFixtures/kotlin/io/libp2p/protocol/OneShotPing.kt @@ -0,0 +1,113 @@ +package io.libp2p.protocol + +import io.libp2p.core.ConnectionClosedException +import io.libp2p.core.Libp2pException +import io.libp2p.core.Stream +import io.libp2p.core.multistream.StrictProtocolBinding +import io.libp2p.etc.types.toByteBuf +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import java.util.concurrent.CompletableFuture + +interface OneShotPingController { + fun ping(): CompletableFuture +} + +/** + * Ping responder responds only once when initiator closes the stream for write + */ +class OneShotPing(pingSize: Int) : OneShotPingBinding(OneShotPingProtocol(pingSize)) { + constructor() : this(32) +} + +open class OneShotPingBinding(ping: OneShotPingProtocol) : + StrictProtocolBinding("/ipfs/one-shot-ping/1.0.0", ping) + +open class OneShotPingProtocol(var pingSize: Int) : ProtocolHandler(Long.MAX_VALUE, Long.MAX_VALUE) { + + constructor() : this(32) + + override fun onStartInitiator(stream: Stream): CompletableFuture { + val handler = OneShotPingInitiator() + stream.pushHandler(handler) + return handler.activeFuture + } + + override fun onStartResponder(stream: Stream): CompletableFuture { + val handler = OneShotPingResponder() + stream.pushHandler(handler) + return CompletableFuture.completedFuture(handler) + } + + open inner class OneShotPingResponder : ProtocolMessageHandler, OneShotPingController { + lateinit var stream: Stream + val outBuf = Unpooled.buffer() + + override fun onActivated(stream: Stream) { + println("OneShotPingResponder: onActivated") + this.stream = stream + } + + override fun onMessage(stream: Stream, msg: ByteBuf) { + println("OneShotPingResponder: onMessage $msg") + outBuf.writeBytes(msg) + } + + override fun onReadClosed(stream: Stream) { + println("OneShotPingResponder: onReadClosed") + stream.writeAndFlush(outBuf) + stream.closeWrite() + } + + override fun onClosed(stream: Stream) { + println("OneShotPingResponder: onClosed") + } + + override fun onException(cause: Throwable?) { + println("OneShotPingResponder: onException: $cause") + } + + override fun ping(): CompletableFuture { + throw Libp2pException("This is ping responder only") + } + } + + open inner class OneShotPingInitiator : ProtocolMessageHandler, OneShotPingController { + val activeFuture = CompletableFuture() + val responseFuture = CompletableFuture() + lateinit var stream: Stream + var closed = false + + override fun onActivated(stream: Stream) { + println("OneShotPingInitiator: onActivated") + this.stream = stream + activeFuture.complete(this) + } + + override fun onMessage(stream: Stream, msg: ByteBuf) { + println("OneShotPingInitiator: onMessage $msg") + responseFuture.complete(null) + } + + override fun onReadClosed(stream: Stream) { + println("OneShotPingInitiator: onReadClosed") + } + + override fun onClosed(stream: Stream) { + println("OneShotPingInitiator: onClosed") + activeFuture.completeExceptionally(ConnectionClosedException()) + } + + override fun onException(cause: Throwable?) { + println("OneShotPingInitiator: onException: $cause") + } + + override fun ping(): CompletableFuture { + println("OneShotPingInitiator: ping") + val data = ByteArray(pingSize) + stream.writeAndFlush(data.toByteBuf()) + stream.closeWrite() + return responseFuture + } + } +} diff --git a/versions.gradle b/versions.gradle index d6f80d536..50736ab7b 100644 --- a/versions.gradle +++ b/versions.gradle @@ -31,7 +31,7 @@ dependencyManagement { entry 'protobuf-java' entry 'protoc' } - dependencySet(group: "io.netty", version: "4.2.4.Final") { + dependencySet(group: "io.netty", version: "4.2.5.Final") { entry 'netty-common' entry 'netty-handler' entry 'netty-transport' @@ -41,7 +41,7 @@ dependencyManagement { entry 'netty-codec-native-quic' entry 'netty-transport-classes-epoll' } - dependency "io.netty:netty-tcnative-boringssl-static:2.0.72.Final" + dependency "io.netty:netty-tcnative-boringssl-static:2.0.73.Final" dependency "com.github.multiformats:java-multibase:v1.1.1" dependency "tech.pegasys:noise-java:22.1.0" dependencySet(group: "org.bouncycastle", version: "1.78.1") { From 8104b864d8659aa5ec3fc258f21f5105b3eb2197 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Tue, 16 Sep 2025 18:06:19 +1200 Subject: [PATCH 12/30] Add quic to interop client (#425) --- Dockerfile | 12 +++++++ interop-test-client/build.gradle.kts | 31 +++++-------------- .../io/libp2p/interop/InteropTestAgent.kt | 16 ++++++---- .../io/libp2p/interop/InteropTestParams.kt | 6 ++-- .../src/test/resources/compose.yaml | 2 ++ 5 files changed, 36 insertions(+), 31 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..6b235db9f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM openjdk:11-jdk AS build +COPY . /jvm-libp2p +WORKDIR /jvm-libp2p +RUN ./gradlew build -x test --no-daemon + +FROM openjdk:11-jdk +WORKDIR /jvm-libp2p +COPY --from=build /jvm-libp2p/interop-test-client/build/distributions/interop-test-client*.tar . +RUN tar -xf interop-test-client*.tar && rm interop-test-client*.tar + +ENTRYPOINT ["/jvm-libp2p/interop-test-client-develop/bin/interop-test-client"] +EXPOSE 4001 \ No newline at end of file diff --git a/interop-test-client/build.gradle.kts b/interop-test-client/build.gradle.kts index df8a9ecf6..3a30ae115 100644 --- a/interop-test-client/build.gradle.kts +++ b/interop-test-client/build.gradle.kts @@ -1,6 +1,12 @@ +import org.jetbrains.kotlin.cli.jvm.compiler.findMainClass + plugins { - id("java") - id("com.bmuschko.docker-java-application") version "9.4.0" + id("application") + id("kotlin") +} + +application { + mainClass = "io.libp2p.interop.InteropTestAgentKt" } dependencies { @@ -8,24 +14,3 @@ dependencies { implementation("redis.clients:jedis:6.1.0") runtimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl") } - -docker { - javaApplication { - baseImage.set("openjdk:11-jdk") - ports.set(listOf(4041)) - } -} - -val composeFileSpec: CopySpec = copySpec { - from("src/test/resources") - include("compose.yaml") -} - -val copyAssets = tasks.register("copyAssets") { - into(layout.buildDirectory.dir("docker")) - with(composeFileSpec) -} - -tasks.dockerCreateDockerfile { - dependsOn(copyAssets) -} \ No newline at end of file diff --git a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt index 831e3b94c..d4639cc8e 100644 --- a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt +++ b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt @@ -28,6 +28,7 @@ import java.util.stream.Collectors import kotlin.random.Random import kotlin.system.exitProcess +const val QUIC_V1 = "quic-v1" private const val REDIS_KEY_LISTENER_ADDRESS = "listenerAddr" class InteropTestAgent(val params: InteropTestParams) { @@ -59,7 +60,7 @@ class InteropTestAgent(val params: InteropTestParams) { .thenCompose { startJedisConnection() } .thenCompose { jedis -> if (params.isDialer) { - startDialer(jedis, node) + startDialer(jedis, node, advertisedAddress) } else { startListener(jedis, advertisedAddress) } @@ -72,7 +73,7 @@ class InteropTestAgent(val params: InteropTestParams) { listenAddresses: ArrayList ): Host = hostJ(Builder.Defaults.None, fn = { it.identity.factory = { privateKey } - if (params.transport == "quic-v1") { + if (params.transport == QUIC_V1) { it.secureTransports.add(QuicTransport::ECDSA) } else { it.transports.add(::TcpTransport) @@ -135,9 +136,13 @@ class InteropTestAgent(val params: InteropTestParams) { /* Start dialer and try to connect with a listener */ - private fun startDialer(jedis: Jedis, node: Host): CompletableFuture { + private fun startDialer( + jedis: Jedis, + node: Host, + advertisedAddress: Multiaddr + ): CompletableFuture { return CompletableFuture.supplyAsync { - printDiagnosticsLog("Starting dialer") + printDiagnosticsLog("Starting dialer with advertisedAddress: $advertisedAddress") val listenerAddresses = jedis.blpop(params.testTimeoutInSeconds, REDIS_KEY_LISTENER_ADDRESS) @@ -175,7 +180,7 @@ class InteropTestAgent(val params: InteropTestParams) { advertisedAddress: Multiaddr ): CompletableFuture { return CompletableFuture.supplyAsync { - println("Starting listener with advertisedAddress: $advertisedAddress") + printDiagnosticsLog("Starting listener with advertisedAddress: $advertisedAddress") jedis.rpush(REDIS_KEY_LISTENER_ADDRESS, advertisedAddress.toString()) @@ -222,7 +227,6 @@ private fun printDiagnosticsLog(msg: String) { System.err.println(msg) } -@SuppressWarnings("unused") fun main() { try { val params = InteropTestParams.Builder().fromEnvironmentVariables().build() diff --git a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt index 86358d69b..73c7823b1 100644 --- a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt +++ b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt @@ -35,8 +35,10 @@ class InteropTestParams( fun build(): InteropTestParams { checkNonEmptyParam("transport", transport) checkNonEmptyParam("muxer", muxer) - checkNonEmptyParam("security", security) - checkNonEmptyParam("redis_addr", security) + if (transport != QUIC_V1) { + checkNonEmptyParam("security", security) + } + checkNonEmptyParam("redis_addr", redisAddress) if (ip == null || ip!!.isBlank()) { ip = "0.0.0.0" diff --git a/interop-test-client/src/test/resources/compose.yaml b/interop-test-client/src/test/resources/compose.yaml index cf3ac37ef..f77f7ed2c 100644 --- a/interop-test-client/src/test/resources/compose.yaml +++ b/interop-test-client/src/test/resources/compose.yaml @@ -24,6 +24,8 @@ services: depends_on: redis: condition: service_started + listener: + condition: service_started redis: image: "redis:7-alpine" ports: From a0123a62d1dd850c960ce3b71bb2a6af946362f6 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Thu, 18 Sep 2025 18:08:15 +1200 Subject: [PATCH 13/30] Fix default redis param and simplifying compose test file (#427) --- interop-test-client/README.md | 48 +++++++++++++++++++ .../io/libp2p/interop/InteropTestParams.kt | 5 +- .../src/test/resources/compose.yaml | 44 ++++++++--------- 3 files changed, 71 insertions(+), 26 deletions(-) create mode 100644 interop-test-client/README.md diff --git a/interop-test-client/README.md b/interop-test-client/README.md new file mode 100644 index 000000000..9eea83c7a --- /dev/null +++ b/interop-test-client/README.md @@ -0,0 +1,48 @@ +# Interop Tests + +For more info: https://github.com/libp2p/test-plans/tree/master/transport-interop#readme + +## Requirements + +To run the interop test framework locally, you need: + +- Docker +- node, nvm and ts-node + +## Running it locally + +The first thing to be able to run the test locally is build the images of each livp2p implementation +being tested. You need to run the following steps for each one of the implementations that you are +planning to run: + +1. Checkout the project https://github.com/libp2p. +2. Navigate to test-plans/impl//; where is the implementation that you want to + build (e.g. `jvm`) and is what version you want (e.g. `v1.2`). +3. Once in the specific version folder run `make` to build the image. This will create a + `image.json` file with the hash of the Docker image built. + +Once you have the images that you want, navigate back to the `transport-interop` folder and run: + +``` +npm test --name-filter=jvm-v1.2 +``` + +The parameter `--name-filter` can be used to limit the pairs that are going to be executed. +In the previous example, only pairs with `jvm-1.2` are going to run. +Similarly, `--name-ignore` can be used to remove pairs. + +Here is the output of a sample run: + +``` +npm test --name-filter=jvm-v1.2 --name-ignore= --verbose=true + +> @libp2p/transport-interop@0.0.1 test +> ts-node src/compose-stdout-helper.ts && ts-node testplans.ts + +Checking jvm-v1.2 x jvm-v1.2 (tcp, tls, mplex)...ACCEPTED (filter match: '*') +Running 1 tests +Running test spec: jvm-v1.2 x jvm-v1.2 (tcp, tls, mplex) +Finished: jvm-v1.2 x jvm-v1.2 (tcp, tls, mplex) { handshakePlusOneRTTMillis: 380, pingRTTMilllis: 2 } +0 failures [] +Run complete +``` \ No newline at end of file diff --git a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt index 73c7823b1..ee54b1b32 100644 --- a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt +++ b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt @@ -38,7 +38,10 @@ class InteropTestParams( if (transport != QUIC_V1) { checkNonEmptyParam("security", security) } - checkNonEmptyParam("redis_addr", redisAddress) + + if (redisAddress == null || redisAddress!!.isBlank()) { + redisAddress = "redis:6379" + } if (ip == null || ip!!.isBlank()) { ip = "0.0.0.0" diff --git a/interop-test-client/src/test/resources/compose.yaml b/interop-test-client/src/test/resources/compose.yaml index f77f7ed2c..6375c37e9 100644 --- a/interop-test-client/src/test/resources/compose.yaml +++ b/interop-test-client/src/test/resources/compose.yaml @@ -1,32 +1,26 @@ -# Compose file used for testing locally services: - listener: - build: . - environment: - transport: "tcp" - muxer: "mplex" - security: "tls" - is_dialer: false - redis_addr: "redis:6379" - test_timeout_seconds: 180 - depends_on: - redis: - condition: service_started dialer: build: . + depends_on: + - redis environment: - transport: "tcp" - muxer: "mplex" - security: "tls" + transport: tcp is_dialer: true - redis_addr: "redis:6379" - test_timeout_seconds: 180 + ip: 0.0.0.0 + muxer: mplex + security: tls + listener: + init: true + build: . depends_on: - redis: - condition: service_started - listener: - condition: service_started + - redis + environment: + transport: tcp + is_dialer: false + ip: 0.0.0.0 + muxer: mplex + security: tls redis: - image: "redis:7-alpine" - ports: - - "6379:6379" \ No newline at end of file + image: redis:7-alpine + environment: + REDIS_ARGS: --loglevel warning \ No newline at end of file From bd921bafd9dedcf9ea5f7978da4bac39fddb3bd3 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Thu, 18 Sep 2025 21:52:52 +1200 Subject: [PATCH 14/30] Fix quic-v1 config (#428) --- .../src/main/kotlin/io/libp2p/interop/InteropTestParams.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt index ee54b1b32..b6ebc8117 100644 --- a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt +++ b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestParams.kt @@ -34,9 +34,9 @@ class InteropTestParams( fun build(): InteropTestParams { checkNonEmptyParam("transport", transport) - checkNonEmptyParam("muxer", muxer) if (transport != QUIC_V1) { checkNonEmptyParam("security", security) + checkNonEmptyParam("muxer", muxer) } if (redisAddress == null || redisAddress!!.isBlank()) { From 09b67d7fab3c7de8e875b973ad70aaa2dae523d5 Mon Sep 17 00:00:00 2001 From: Stefan Bratanov Date: Fri, 19 Sep 2025 02:38:14 +0100 Subject: [PATCH 15/30] Add websocket to interop client (#429) --- interop-test-client/README.md | 4 +-- .../io/libp2p/interop/InteropTestAgent.kt | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/interop-test-client/README.md b/interop-test-client/README.md index 9eea83c7a..0cb7bdf97 100644 --- a/interop-test-client/README.md +++ b/interop-test-client/README.md @@ -15,8 +15,8 @@ The first thing to be able to run the test locally is build the images of each l being tested. You need to run the following steps for each one of the implementations that you are planning to run: -1. Checkout the project https://github.com/libp2p. -2. Navigate to test-plans/impl//; where is the implementation that you want to +1. Checkout the project https://github.com/libp2p/test-plans. +2. Navigate to transport-interop/impl//; where is the implementation that you want to build (e.g. `jvm`) and is what version you want (e.g. `v1.2`). 3. Once in the specific version folder run `make` to build the image. This will create a `image.json` file with the hash of the Docker image built. diff --git a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt index d4639cc8e..6070ffda8 100644 --- a/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt +++ b/interop-test-client/src/main/kotlin/io/libp2p/interop/InteropTestAgent.kt @@ -21,6 +21,7 @@ import io.libp2p.security.noise.NoiseXXSecureChannel import io.libp2p.security.tls.TlsSecureChannel.Companion.ECDSA import io.libp2p.transport.quic.QuicTransport import io.libp2p.transport.tcp.TcpTransport +import io.libp2p.transport.ws.WsTransport import redis.clients.jedis.Jedis import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @@ -28,6 +29,8 @@ import java.util.stream.Collectors import kotlin.random.Random import kotlin.system.exitProcess +const val TCP = "tcp" +const val WS = "ws" const val QUIC_V1 = "quic-v1" private const val REDIS_KEY_LISTENER_ADDRESS = "listenerAddr" @@ -38,12 +41,19 @@ class InteropTestAgent(val params: InteropTestParams) { init { val port = 10000 + Random.nextInt(50000) - val isTcp = "tcp" == params.transport - val ip = params.ip - val protocol = if (isTcp) "tcp" else "udp" - val maybeQuicSuffix = (if (isTcp) "" else "/quic-v1") + val transport = params.transport + val protocol = when (transport) { + TCP -> TCP + WS -> TCP + else -> "udp" + } + val maybeSuffix = when (transport) { + TCP -> "" + WS -> "/ws" + else -> "/quic-v1" + } val address = - Multiaddr.fromString("/ip4/$ip/$protocol/${port}$maybeQuicSuffix") + Multiaddr.fromString("/ip4/${params.ip}/$protocol/${port}$maybeSuffix") val privateKey = generateEd25519KeyPair().first val peerID = fromPubKey(privateKey.publicKey()) @@ -73,10 +83,10 @@ class InteropTestAgent(val params: InteropTestParams) { listenAddresses: ArrayList ): Host = hostJ(Builder.Defaults.None, fn = { it.identity.factory = { privateKey } - if (params.transport == QUIC_V1) { - it.secureTransports.add(QuicTransport::ECDSA) - } else { - it.transports.add(::TcpTransport) + when (params.transport) { + QUIC_V1 -> it.secureTransports.add(QuicTransport::ECDSA) + WS -> it.transports.add(::WsTransport) + else -> it.transports.add(::TcpTransport) } if ("noise" == params.security) { From 737456de5d96db030aa04c8527585af1a510898f Mon Sep 17 00:00:00 2001 From: Dave Huseby Date: Wed, 5 Nov 2025 00:46:42 -0700 Subject: [PATCH 16/30] Switch base image from OpenJDK to Eclipse Temurin fixed the problem of openjdk images being gone --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6b235db9f..4971e5a0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ -FROM openjdk:11-jdk AS build +FROM eclipse-temurin:11-jdk AS build COPY . /jvm-libp2p WORKDIR /jvm-libp2p RUN ./gradlew build -x test --no-daemon -FROM openjdk:11-jdk +FROM eclipse-temurin:11-jdk WORKDIR /jvm-libp2p COPY --from=build /jvm-libp2p/interop-test-client/build/distributions/interop-test-client*.tar . RUN tar -xf interop-test-client*.tar && rm interop-test-client*.tar ENTRYPOINT ["/jvm-libp2p/interop-test-client-develop/bin/interop-test-client"] -EXPOSE 4001 \ No newline at end of file +EXPOSE 4001 From c2554a6acbe657f461dd6d3385e32323d513a0a0 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Wed, 28 Jan 2026 11:19:39 +1300 Subject: [PATCH 17/30] Updated rpc protobuf definition for partial messages (#434) * Updated rpc protobuf definition for partial messages * fix spaces * fix typo --- libp2p/src/main/proto/rpc.proto | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/libp2p/src/main/proto/rpc.proto b/libp2p/src/main/proto/rpc.proto index 080eef471..91469bbe3 100644 --- a/libp2p/src/main/proto/rpc.proto +++ b/libp2p/src/main/proto/rpc.proto @@ -7,11 +7,16 @@ message RPC { repeated Message publish = 2; message SubOpts { - optional bool subscribe = 1; // subscribe or unsubcribe + optional bool subscribe = 1; // subscribe or unsubscribe optional string topicid = 2; + // signals to receiver that sender prefers partial messages + optional bool requestsPartial = 3; + // signals to receiver that sender supports sending partial messages + optional bool supportsSendingPartial = 4; } optional ControlMessage control = 3; + optional PartialMessagesExtension partial = 10; } message Message { @@ -29,6 +34,7 @@ message ControlMessage { repeated ControlGraft graft = 3; repeated ControlPrune prune = 4; repeated ControlIDontWant idontwant = 5; + optional ControlExtensions extensions = 6; } message ControlIHave { @@ -54,11 +60,22 @@ message ControlIDontWant { repeated bytes messageIDs = 1; } +message ControlExtensions { + optional bool partialMessages = 10; +} + message PeerInfo { optional bytes peerID = 1; optional bytes signedPeerRecord = 2; } +message PartialMessagesExtension { + optional string topicID = 1; + optional bytes groupID = 2; + optional bytes partialMessage = 3; + optional bytes partsMetadata = 4; +} + message TopicDescriptor { optional string name = 1; optional AuthOpts auth = 2; From eff9dd444d042ceee1ea6470b48b93226a33490c Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Fri, 13 Feb 2026 11:30:42 +1300 Subject: [PATCH 18/30] Add support for Extension Control Messages (gossipsub v1.3) 1/2 (#438) --- .gitignore | 6 +- CLAUDE.md | 314 ++++++++++++++++++ .../kotlin/io/libp2p/pubsub/AbstractRouter.kt | 9 + .../kotlin/io/libp2p/pubsub/PubsubProtocol.kt | 8 + .../io/libp2p/pubsub/flood/FloodRouter.kt | 4 + .../kotlin/io/libp2p/pubsub/gossip/Gossip.kt | 8 + .../io/libp2p/pubsub/gossip/GossipRouter.kt | 70 ++++ libp2p/src/main/proto/rpc.proto | 11 + .../libp2p/pubsub/gossip/GossipV1_3Tests.kt | 19 ++ .../GossipExtensionsMessageHandlingTest.kt | 103 ++++++ .../kotlin/io/libp2p/pubsub/MockRouter.kt | 12 +- 11 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_3Tests.kt create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt diff --git a/.gitignore b/.gitignore index 5b5778198..e2ca52ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -192,4 +192,8 @@ package-lock.json /src/jmh/java/generated/ #Jenv -.java-version \ No newline at end of file +.java-version + +# Claude +CLAUDE.local.md +.claude/settings.local.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..79fd53bfd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,314 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +jvm-libp2p is a JVM implementation of the [libp2p](https://libp2p.io/) networking stack, written in Kotlin. It provides peer-to-peer networking capabilities including transport protocols (TCP, QUIC, WebSocket), security channels (Noise, TLS), stream multiplexing (Yamux, Mplex), and pub/sub messaging (Gossipsub, Floodsub). + +Notable users: Teku (Ethereum Consensus Layer client), Nabu (minimal IPFS), Peergos (peer-to-peer encrypted filesystem). + +## Build Commands + +```bash +# Build the entire project +./gradlew build + +# Run all tests (excludes interop tests tagged with "interop") +./gradlew test + +# Run tests for a specific module +./gradlew :libp2p:test + +# Run a specific test class +./gradlew :libp2p:test --tests "io.libp2p.pubsub.gossip.GossipRpcPartsQueueTest" + +# Run a specific test method +./gradlew :libp2p:test --tests "io.libp2p.pubsub.gossip.GossipRpcPartsQueueTest.mergeMessageParts*" + +# Check code formatting +./gradlew spotlessCheck + +# Apply code formatting +./gradlew spotlessApply + +# Run static analysis (Detekt) +./gradlew detekt + +# Generate documentation +./gradlew dokkaHtml +# Output in build/dokka/ + +# Clean build artifacts +./gradlew clean +``` + +**Requirements:** JDK 11 or higher + +**Module Structure:** +- `:libp2p` - Main library module +- `:tools:simulator` - Gossip network simulator +- `:tools:schedulers` - Test scheduling utilities +- `:examples:chatter`, `:examples:cli-chatter`, `:examples:pinger` - Example applications +- `:interop-test-client` - Interoperability testing client + +## Architecture Overview + +### Core Abstraction Layers + +The library follows a layered architecture with protocol negotiation at each layer: + +``` +Application Layer + ↓ (Protocol negotiation via multistream-select) +Stream/Protocol Layer (PingProtocol, ChatProtocol, PubsubRouter) + ↓ (Stream creation) +Stream Multiplexing Layer (Yamux, Mplex) + ↓ (Multiplexer negotiation) +Security Layer (Noise, TLS) + ↓ (Security negotiation) +Transport Layer (TCP, QUIC, WebSocket) + ↓ +Raw Network +``` + +### Key Interfaces and Their Roles + +**`Host`** (`core/Host.kt`): +- Main entry point for all libp2p operations +- Manages identity (`PeerId`, `PrivKey`), network, and protocol handlers +- Created via DSL builder: `host { identity { ... }; transports { ... }; protocols { ... } }` + +**`Network`** (`core/Network.kt`): +- Manages transports and active connections +- Handles `listen()` and `dial()` operations +- Reuses connections to the same peer + +**`Connection`** and **`Stream`** (both extend `P2PChannel`): +- `Connection`: Secured, multiplexed connection between two peers +- `Stream`: Logical stream over a connection for a specific protocol + +**`Transport`** (`transport/Transport.kt`): +- Handles raw connection establishment (TCP, QUIC, WebSocket) +- Each transport parses specific multiaddr formats (e.g., `/ip4/127.0.0.1/tcp/30333`) + +**`SecureChannel`** (`security/SecureChannel.kt`): +- Protocol binding for security layer negotiation +- Returns `SecureChannel.Session` with `remoteId`, `remotePubKey` +- Implementations: `NoiseXXSecureChannel` (production), `TlsSecureChannel` (beta) + +**`StreamMuxer`** (`mux/StreamMuxer.kt`): +- Protocol binding for multiplexer negotiation +- Returns `StreamMuxer.Session` for creating/receiving streams +- Implementations: `MplexStreamMuxer` (production), `YamuxStreamMuxer` (beta) + +### The Connection Upgrade Pipeline + +When a raw transport connection is established, it goes through staged upgrades: + +``` +1. Raw Transport (TCP/QUIC/WS) + ↓ +2. ConnectionBuilder (transport/implementation/ConnectionBuilder.kt) + ↓ +3. Security Negotiation → SecureChannel.Session + ↓ +4. Multiplexer Negotiation → StreamMuxer.Session + ↓ +5. Full Connection Ready → ConnectionOverNetty +``` + +**Key Class:** `ConnectionUpgrader` (`transport/implementation/ConnectionUpgrader.kt`) +- Orchestrates security and muxer protocol negotiation +- Uses `MultistreamProtocol` for protocol selection +- Supports early muxer negotiation (TLS 1.3 feature) + +### Protocol Handler Pattern + +Custom protocols implement `ProtocolHandler`: + +```kotlin +// Define protocol binding +StrictProtocolBinding("/ipfs/ping/1.0.0", PingProtocol()) + +// Implement handler +class PingProtocol : ProtocolHandler { + override fun onStartInitiator(stream: Stream): CompletableFuture + override fun onStartResponder(stream: Stream): CompletableFuture +} +``` + +See `examples/chatter/ChatProtocol.kt` for a complete example. + +### Pub/Sub Architecture + +The pub/sub system is located in `pubsub/` and follows this structure: + +**`AbstractRouter`** (`pubsub/AbstractRouter.kt`): +- Base class providing common pubsub logic +- Manages peer subscriptions via `peersTopics` (multi-bimap) +- Implements message validation, deduplication (via `SeenCache`), and batching +- Uses single-threaded event loop (`P2PService`) for thread-safety + +**Message Batching via `RpcPartsQueue`**: +- Per-peer queue that accumulates message parts before transmission +- Pattern: accumulate parts → flush via `takeMerged()` → send merged RPC +- Default implementation merges all parts into single RPC +- Gossip implementation (`GossipRpcPartsQueue`) splits messages to respect per-category limits + +**Message Flow:** +``` +Outbound: publish() → validateAndBroadcast() → submitPublishMessage(peer) + → queue.addPublish() → flushPending() → queue.takeMerged() → send() + +Inbound: channelRead() → onInbound() → validate & deduplicate + → broadcastInbound() → queue.addPublish() → flushPending() +``` + +**Gossip-Specific:** +- **`GossipRouter`** extends `AbstractRouter` with mesh topology management +- Heartbeat mechanism for GRAFT/PRUNE/IHAVE/IWANT control messages +- Peer scoring for spam resistance +- Control messages batched via `GossipRpcPartsQueue` + +**Key Flush Triggers:** +- After processing inbound messages (sync validation complete) +- After async message validation completes +- On peer activation (sends initial subscriptions) +- During Gossip heartbeat (mesh management operations) +- After explicit publish/subscribe API calls + +### Multistream Protocol Negotiation + +**`MultistreamProtocol`** (`protocol/multistream/MultistreamProtocol.kt`): +- Used at three layers: security negotiation, muxer negotiation, protocol negotiation +- Contains list of `ProtocolBinding`s with protocol names +- Delegates to `Negotiator` (initiator/responder) +- Completes with `ProtocolSelect` containing selected protocol handler + +**Pattern:** Any negotiable component extends `ProtocolBinding`: +- Security channels, stream muxers, application protocols all use this pattern + +## Development Patterns + +### Netty Integration + +All protocol logic is implemented as Netty `ChannelHandler`s: +- **`P2PChannelOverNetty`**: Base wrapper for both `Connection` and `Stream` +- **`ConnectionOverNetty`**: Wraps connection-level channel with secure and muxer sessions +- **`StreamOverNetty`**: Wraps stream-level channel with protocol negotiation + +### Async Pattern + +Extensive use of `CompletableFuture` for async operations: +- Protocol negotiation with timeouts +- Connection establishment across multiple addresses +- Message publishing and validation + +### Event Thread Safety + +The pub/sub system (and other components) use single-threaded event loops via `P2PService`: +- All operations run on `executor: ScheduledExecutorService` +- Components like `RpcPartsQueue` are explicitly "NOT thread safe" but guaranteed single-threaded access +- Methods: `runOnEventThread {}`, `submitOnEventThread {}`, `submitAsyncOnEventThread {}` + +### Testing Patterns + +**JUnit 5** with: +- `@Test` for standard tests +- `@ParameterizedTest` with `@MethodSource` for data-driven tests +- AssertJ for fluent assertions (`assertThat(...)`) +- MockK for mocking + +**Test Infrastructure:** +- Test fixtures in `src/testFixtures/` for shared test utilities +- Host builder DSL used extensively in tests +- `TestChannel` and `TestLogAppender` utilities + +**Example Test Pattern (from GossipRpcPartsQueueTest):** +```kotlin +@ParameterizedTest +@MethodSource("testCases") +fun `test message merging`(params: GossipParams, queue: TestQueue) { + val monolith = queue.mergedSingle() // Ground truth + val split = queue.takeMerged() // Actual implementation + + // Verify limits respected + assertThat(split).allMatch { router.validateMessageListLimits(it) } + + // Verify semantic equivalence + assertThat(split.merge().disperse()).isEqualTo(monolith.disperse()) +} +``` + +### Code Style + +- Kotlin 1.6 with JVM target 11 +- ktlint formatting (run `./gradlew spotlessApply`) +- Detekt static analysis +- Wildcard imports allowed +- No trailing commas enforced +- All warnings as errors (`allWarningsAsErrors = true`) + +## Important Implementation Details + +### Protobuf Code Generation + +Protobuf definitions in `src/main/proto/` are compiled via `com.google.protobuf` Gradle plugin. +Generated code in `build/generated/source/proto/main/java/`. + +To regenerate: `./gradlew :libp2p:clean :libp2p:build` + +### Multiaddr Format + +Network addresses use multiaddr format: +- Example: `/ip4/127.0.0.1/tcp/30333/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N` +- Parsed/managed in `core/multiformats/` +- Each transport validates specific multiaddr components + +### PeerId Generation + +`PeerId` is derived from peer's public key: +- Multihash of the public key bytes +- 32-50 bytes depending on key type +- Supports RSA, Ed25519, Secp256k1, ECDSA + +### Security Handshake Timeout + +Default timeout for security handshakes: **5 seconds** +- Applies to Noise and TLS handshakes +- Configurable in protocol implementations + +## Common Development Workflows + +### Adding a New Protocol + +1. Define protocol binding with multistream name (e.g., `/myapp/myprotocol/1.0.0`) +2. Implement `ProtocolHandler` with initiator/responder logic +3. Register with Host via `protocols { add(...) }` in builder +4. Implement controller interface for protocol operations + +See `examples/chatter/` for a complete example. + +### Adding a New Transport + +1. Extend `Transport` interface +2. Implement `listen()` and `dial()` for raw connection establishment +3. Delegate to `ConnectionUpgrader` for security/muxer negotiation +4. Add multiaddr parsing logic for transport-specific components +5. Register with Host via `transports { add(...) }` + +### Debugging Connection Issues + +- Use `ConnectionVisitor` and `StreamVisitor` for lifecycle observation +- Enable debug logging for `io.libp2p` package +- Check multiaddr format compatibility between peers +- Verify protocol versions match (especially for security/muxer) + +### Working with Pub/Sub + +- All pub/sub operations run on event thread (thread-safe by design) +- Message validation happens before broadcasting +- Seen cache prevents duplicate message processing +- Control messages automatically batched for efficiency +- Gossip mesh heartbeat runs every 1 second (default) diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt index d5e16401c..a72b93ccf 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt @@ -139,6 +139,11 @@ abstract class AbstractRouter( */ protected abstract fun processControl(ctrl: Rpc.ControlMessage, receivedFrom: PeerHandler) + /** + * Processes Gossipsub extensions messages + */ + protected abstract fun processExtensions(msg: Rpc.RPC, receivedFrom: PeerHandler) + override fun onPeerActive(peer: PeerHandler) { val partsQueue = pendingRpcParts.getQueue(peer) subscribedTopics.forEach { @@ -180,6 +185,10 @@ abstract class AbstractRouter( processControl(msg.control, peer) } + if (protocol.supportsExtensions()) { + processExtensions(msg, peer) + } + val (msgSubscribed, nonSubscribed) = msg.publishList .partition { rpcMsg -> rpcMsg.topicIDsList.any { it in subscribedTopics } } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt index 49cf95239..2d421c58c 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt @@ -7,6 +7,7 @@ enum class PubsubProtocol(val announceStr: ProtocolId) { Gossip_V_1_0("/meshsub/1.0.0"), Gossip_V_1_1("/meshsub/1.1.0"), Gossip_V_1_2("/meshsub/1.2.0"), + Gossip_V_1_3("/meshsub/1.3.0"), Floodsub("/floodsub/1.0.0"); companion object { @@ -27,4 +28,11 @@ enum class PubsubProtocol(val announceStr: ProtocolId) { fun supportsIDontWant(): Boolean { return this == Gossip_V_1_2 } + + /** + * https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.3.md#the-extensions-control-message + */ + fun supportsExtensions(): Boolean { + return this == Gossip_V_1_3 + } } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/flood/FloodRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/flood/FloodRouter.kt index 9bed00ddd..acb4e912e 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/flood/FloodRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/flood/FloodRouter.kt @@ -36,6 +36,10 @@ class FloodRouter(executor: ScheduledExecutorService = Executors.newSingleThread // NOP } + override fun processExtensions(msg: Rpc.RPC, receivedFrom: PeerHandler) { + // NOP + } + private fun broadcast(msg: PubsubMessage, receivedFrom: PeerHandler?): CompletableFuture { val peers = msg.topics .map { getTopicPeers(it) } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt index ae5f3c5e2..56b16268e 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt @@ -31,6 +31,14 @@ class Gossip @JvmOverloads constructor( override val protocolDescriptor = when (router.protocol) { + PubsubProtocol.Gossip_V_1_3 -> { + ProtocolDescriptor( + PubsubProtocol.Gossip_V_1_3.announceStr, + PubsubProtocol.Gossip_V_1_2.announceStr, + PubsubProtocol.Gossip_V_1_1.announceStr, + PubsubProtocol.Gossip_V_1_0.announceStr + ) + } PubsubProtocol.Gossip_V_1_2 -> { ProtocolDescriptor( PubsubProtocol.Gossip_V_1_2.announceStr, diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt index b385aaa3b..535d6acf1 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt @@ -132,6 +132,8 @@ open class GossipRouter( private val acceptRequestsWhitelist = mutableMapOf() override val pendingRpcParts = PendingRpcPartsMap { DefaultGossipRpcPartsQueue(params) } + private val peerExtensionSupportMap = mutableMapOf() + private fun setBackOff(peer: PeerHandler, topic: Topic) = setBackOff(peer, topic, params.pruneBackoff.toMillis()) private fun setBackOff(peer: PeerHandler, topic: Topic, delay: Long) { backoffExpireTimes[peer.peerId to topic] = currentTimeSupplier() + delay @@ -384,6 +386,74 @@ open class GossipRouter( ctrl.run { (graftList + pruneList + ihaveList + iwantList + idontwantList) }.forEach { processControlMessage(it, receivedFrom) } + + if (protocol.supportsExtensions() && ctrl.hasExtensions()) { + processControlExtensions(ctrl.extensions, receivedFrom) + } + } + + private fun processControlExtensions( + ctrlExtensions: Rpc.ControlExtensions, + receivedFrom: PeerHandler + ) { + logger.trace("Received control extension {}", ctrlExtensions.toString()) + + if (peerExtensionSupportMap[receivedFrom.peerId] != null) { + // TODO Should downscore peers that send control extension multiple times? (https://github.com/libp2p/jvm-libp2p/issues/437) + logger.trace( + "Received another control extension message from peer {}", + receivedFrom.peerId + ) + return + } else { + peerExtensionSupportMap[receivedFrom.peerId] = ctrlExtensions + } + } + + override fun processExtensions(msg: Rpc.RPC, receivedFrom: PeerHandler) { + val peerSupportedExtensions = peerExtensionSupportMap[receivedFrom.peerId] + if (peerSupportedExtensions == null) { + logger.trace( + "Ignoring extension messages from peer {} - did it send an extension control message?", + receivedFrom.peerId + ) + } else { + when { + peerSupportedExtensions.hasTestExtension() && msg.hasTestExtension() -> + processTestExtensionMessage(msg.testExtension, receivedFrom) + + peerSupportedExtensions.hasPartialMessages() && msg.hasPartial() -> + processPartialMessageExtension(msg.partial, receivedFrom) + } + } + } + + private fun processTestExtensionMessage( + testExtensionMessage: Rpc.TestExtension, + receivedFrom: PeerHandler + ) { + logger.trace( + "Processing test extension message {} from {}", + testExtensionMessage.toByteArray(), + receivedFrom.peerId + ) + + val response = + Rpc.RPC.newBuilder().setTestExtension(Rpc.TestExtension.newBuilder().build()).build() + + send(receivedFrom, response) + } + + private fun processPartialMessageExtension( + partialMessagesExtension: Rpc.PartialMessagesExtension, + receivedFrom: PeerHandler + ) { + logger.trace( + "Processing partial message extension message {} from {}", + partialMessagesExtension.toString(), + receivedFrom.peerId + ) + // TODO: implement partial message handling (https://github.com/libp2p/jvm-libp2p/issues/435) } override fun broadcastInbound(msgs: List, receivedFrom: PeerHandler) { diff --git a/libp2p/src/main/proto/rpc.proto b/libp2p/src/main/proto/rpc.proto index 91469bbe3..7ff5e2bc8 100644 --- a/libp2p/src/main/proto/rpc.proto +++ b/libp2p/src/main/proto/rpc.proto @@ -16,7 +16,12 @@ message RPC { } optional ControlMessage control = 3; + + // Canonical Extensions optional PartialMessagesExtension partial = 10; + + // Experimental Extensions + optional TestExtension testExtension = 6492434; } message Message { @@ -62,6 +67,10 @@ message ControlIDontWant { message ControlExtensions { optional bool partialMessages = 10; + + // Experimental extensions must use field numbers larger than 0x200000 to be + // encoded with at least 4 bytes + optional bool testExtension = 6492434; } message PeerInfo { @@ -103,3 +112,5 @@ message TopicDescriptor { } } } + +message TestExtension {} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_3Tests.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_3Tests.kt new file mode 100644 index 000000000..72497b4d7 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_3Tests.kt @@ -0,0 +1,19 @@ +@file:Suppress("ktlint:standard:class-naming") + +package io.libp2p.pubsub.gossip + +import io.libp2p.pubsub.PubsubProtocol +import org.junit.jupiter.api.Test + +class GossipV1_3Tests : GossipTestsBase() { + + @Test + fun selfSanityTest() { + val test = TwoRoutersTest(protocol = PubsubProtocol.Gossip_V_1_3) + + test.mockRouter.subscribe("topic1") + val msg = newMessage("topic1", 0L, "Hello".toByteArray()) + test.gossipRouter.publish(msg) + test.mockRouter.waitForMessage { it.publishCount > 0 } + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt new file mode 100644 index 000000000..e9c97ce85 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt @@ -0,0 +1,103 @@ +package io.libp2p.pubsub.gossip.extensions + +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.gossip.GossipTestsBase +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import pubsub.pb.Rpc +import java.util.concurrent.TimeoutException + +class GossipExtensionsMessageHandlingTest : GossipTestsBase() { + + @Test + fun `extension messages sent to peer prior to gossip v1_3 are ignored`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_2 + ) + + val rpcMessageWithControlExtensionAndTestExtensionMessages = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .setExtensions(Rpc.ControlExtensions.newBuilder().setTestExtension(true)) + .build() + ) + .setTestExtension(Rpc.TestExtension.newBuilder().build()) + .build() + test.mockRouter.sendToSingle(rpcMessageWithControlExtensionAndTestExtensionMessages) + + assertNoResponseFromTestExtension(test) + } + + @Test + fun `extension messages sent to peer prior to sending extension control messages are ignored`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3 + ) + + val rpcMessageWithTestExtension = + Rpc.RPC.newBuilder().setTestExtension(testExtensionMessage).build() + test.mockRouter.sendToSingle(rpcMessageWithTestExtension) + + assertNoResponseFromTestExtension(test) + } + + @Test + fun `extension message flow with extension control message before actual extension message`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3 + ) + + val rpcMessageWithControl = Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().setExtensions(controlExtensionMessage()) + ).build() + test.mockRouter.sendToSingle(rpcMessageWithControl) + + val rpcMessageWithTestExtension = + Rpc.RPC.newBuilder().setTestExtension(testExtensionMessage).build() + test.mockRouter.sendToSingle(rpcMessageWithTestExtension) + + test.mockRouter.waitForMessage { it.hasTestExtension() } + } + + @Test + fun `extension message flow with extension control and extension message in the same rpc message`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3 + ) + + val rpcMessageWithControlExtensionAndTestExtensionMessages = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .setExtensions(Rpc.ControlExtensions.newBuilder().setTestExtension(true)) + .build() + ) + .setTestExtension(Rpc.TestExtension.newBuilder().build()) + .build() + test.mockRouter.sendToSingle(rpcMessageWithControlExtensionAndTestExtensionMessages) + + test.mockRouter.waitForMessage { it.hasTestExtension() } + } + + companion object { + val testExtensionControlEnabledMessage: Rpc.RPC = Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder() + .setExtensions(Rpc.ControlExtensions.newBuilder().setTestExtension(true).build()) + .build() + ).build() + + fun controlExtensionMessage(testExtensionEnabled: Boolean = false): Rpc.ControlExtensions { + return Rpc.ControlExtensions.newBuilder().setTestExtension(testExtensionEnabled).build() + } + + val testExtensionMessage: Rpc.TestExtension = Rpc.TestExtension.newBuilder().build() + + fun assertNoResponseFromTestExtension(test: TwoRoutersTest) { + assertThrows { + test.mockRouter.waitForMessage( + { it.hasTestExtension() }, + 500L + ) + } + } + } +} diff --git a/libp2p/src/testFixtures/kotlin/io/libp2p/pubsub/MockRouter.kt b/libp2p/src/testFixtures/kotlin/io/libp2p/pubsub/MockRouter.kt index d214fd7bb..9df88819b 100644 --- a/libp2p/src/testFixtures/kotlin/io/libp2p/pubsub/MockRouter.kt +++ b/libp2p/src/testFixtures/kotlin/io/libp2p/pubsub/MockRouter.kt @@ -8,6 +8,8 @@ import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException +private const val DEFAULT_WAIT_FOR_MESSAGE_TIMEOUT_IN_MILLIS = 5000L + open class MockRouter(executor: ScheduledExecutorService) : AbstractRouter( protocol = PubsubProtocol.Floodsub, executor = executor, @@ -26,9 +28,16 @@ open class MockRouter(executor: ScheduledExecutorService) : AbstractRouter( } fun waitForMessage(predicate: (Rpc.RPC) -> Boolean): Rpc.RPC { + return waitForMessage(predicate, DEFAULT_WAIT_FOR_MESSAGE_TIMEOUT_IN_MILLIS) + } + + fun waitForMessage( + predicate: (Rpc.RPC) -> Boolean, + timeoutInMillis: Long = DEFAULT_WAIT_FOR_MESSAGE_TIMEOUT_IN_MILLIS + ): Rpc.RPC { var cnt = 0 while (true) { - val msg = inboundMessages.poll(5, TimeUnit.SECONDS) + val msg = inboundMessages.poll(timeoutInMillis, TimeUnit.MILLISECONDS) ?: throw TimeoutException("No matching message received among $cnt") if (predicate(msg)) return msg cnt++ @@ -47,4 +56,5 @@ open class MockRouter(executor: ScheduledExecutorService) : AbstractRouter( override fun broadcastOutbound(msg: PubsubMessage): CompletableFuture = CompletableFuture.completedFuture(null) override fun broadcastInbound(msgs: List, receivedFrom: PeerHandler) {} override fun processControl(ctrl: Rpc.ControlMessage, receivedFrom: PeerHandler) {} + override fun processExtensions(msg: Rpc.RPC, receivedFrom: PeerHandler) {} } From 1ba3fc4160ff22b9e6139518d0acf0f27ab467f5 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Wed, 18 Feb 2026 11:17:11 +1300 Subject: [PATCH 19/30] Sending control extension message to remote peers (#442) --- .../kotlin/io/libp2p/pubsub/AbstractRouter.kt | 4 +- .../kotlin/io/libp2p/pubsub/gossip/Gossip.kt | 4 + .../pubsub/gossip/GossipExtensionsState.kt | 38 ++ .../io/libp2p/pubsub/gossip/GossipRouter.kt | 85 ++++- .../pubsub/gossip/GossipRpcPartsQueue.kt | 13 + .../gossip/GossipExtensionsStateTest.kt | 358 ++++++++++++++++++ .../pubsub/gossip/GossipRpcPartsQueueTest.kt | 187 +++++++++ .../GossipExtensionsMessageHandlingTest.kt | 191 ++++++++-- 8 files changed, 824 insertions(+), 56 deletions(-) create mode 100644 libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsStateTest.kt diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt index a72b93ccf..66f7e4ab0 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt @@ -185,7 +185,9 @@ abstract class AbstractRouter( processControl(msg.control, peer) } - if (protocol.supportsExtensions()) { + // TODO we need to handle the existence of extension messages more generically (https://github.com/libp2p/jvm-libp2p/issues/441) + + if (protocol.supportsExtensions() && (msg.hasTestExtension() || msg.hasPartial())) { processExtensions(msg, peer) } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt index 56b16268e..39100f10c 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt @@ -12,6 +12,7 @@ import io.libp2p.pubsub.PubsubApiImpl import io.libp2p.pubsub.PubsubProtocol import io.libp2p.pubsub.gossip.builders.GossipRouterBuilder import io.netty.channel.ChannelHandler +import org.slf4j.LoggerFactory import java.util.concurrent.CompletableFuture class Gossip @JvmOverloads constructor( @@ -21,6 +22,8 @@ class Gossip @JvmOverloads constructor( ) : ProtocolBinding, ConnectionHandler, PubsubApi by api { + private val logger = LoggerFactory.getLogger(Gossip::class.java) + fun updateTopicScoreParams(scoreParams: Map) { router.score.updateTopicParams(scoreParams) } @@ -62,6 +65,7 @@ class Gossip @JvmOverloads constructor( } override fun initChannel(ch: P2PChannel, selectedProtocol: String): CompletableFuture { + logger.trace("Gossip initChannel - selected protocol: {}", selectedProtocol) router.addPeerWithDebugHandler(ch as Stream, debugGossipHandler) return CompletableFuture.completedFuture(Unit) } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt new file mode 100644 index 000000000..24daf6ade --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt @@ -0,0 +1,38 @@ +package io.libp2p.pubsub.gossip + +import io.libp2p.core.PeerId +import pubsub.pb.Rpc + +class GossipExtensionsState { + + /* + Tracks the peers that we have already sent a control extensions message + */ + private val outgoingControlExtensionsMsgPeers: MutableSet = mutableSetOf() + + /* + Tracks peers that already sent us a control extensions message + */ + private val peerExtensionSupportMap: MutableMap = mutableMapOf() + + fun onPeerDisconnected(peer: PeerId) { + outgoingControlExtensionsMsgPeers.remove(peer) + peerExtensionSupportMap.remove(peer) + } + + fun onControlExtensionsMessage(ctrlExtensions: Rpc.ControlExtensions, receivedFrom: PeerId) { + peerExtensionSupportMap[receivedFrom] = ctrlExtensions + } + + fun registerControlExtensionMessageSentToPeers(peerId: PeerId) { + outgoingControlExtensionsMsgPeers.add(peerId) + } + + fun peerSupportedExtensions(peerId: PeerId) = peerExtensionSupportMap[peerId] + + fun hasReceivedControlExtensionsFrom(peer: PeerId) = + peerExtensionSupportMap.contains(peer) + + fun hasSentControlExtensionsTo(peer: PeerId) = + outgoingControlExtensionsMsgPeers.contains(peer) +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt index 535d6acf1..1bb463436 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt @@ -132,7 +132,7 @@ open class GossipRouter( private val acceptRequestsWhitelist = mutableMapOf() override val pendingRpcParts = PendingRpcPartsMap { DefaultGossipRpcPartsQueue(params) } - private val peerExtensionSupportMap = mutableMapOf() + val gossipExtensionsState = GossipExtensionsState() private fun setBackOff(peer: PeerHandler, topic: Topic) = setBackOff(peer, topic, params.pruneBackoff.toMillis()) private fun setBackOff(peer: PeerHandler, topic: Topic, delay: Long) { @@ -159,6 +159,7 @@ open class GossipRouter( fanout.values.forEach { it.remove(peer) } acceptRequestsWhitelist -= peer pendingRpcParts.popQueue(peer) // discard them + gossipExtensionsState.onPeerDisconnected(peer.peerId) super.onPeerDisconnected(peer) } @@ -166,6 +167,7 @@ open class GossipRouter( super.onPeerActive(peer) eventBroadcaster.notifyConnected(peer.peerId, peer.getRemoteAddress()) heartbeatTask.hashCode() // force lazy initialization + sendControlExtensions(peer) } override fun notifyUnseenMessage(peer: PeerHandler, msg: PubsubMessage) { @@ -398,34 +400,56 @@ open class GossipRouter( ) { logger.trace("Received control extension {}", ctrlExtensions.toString()) - if (peerExtensionSupportMap[receivedFrom.peerId] != null) { - // TODO Should downscore peers that send control extension multiple times? (https://github.com/libp2p/jvm-libp2p/issues/437) + if (gossipExtensionsState.hasReceivedControlExtensionsFrom(receivedFrom.peerId)) { + // TODO Should disconnect peers that send control extension multiple times (https://github.com/libp2p/jvm-libp2p/issues/437) logger.trace( "Received another control extension message from peer {}", receivedFrom.peerId ) return } else { - peerExtensionSupportMap[receivedFrom.peerId] = ctrlExtensions + gossipExtensionsState.onControlExtensionsMessage(ctrlExtensions, receivedFrom.peerId) } } override fun processExtensions(msg: Rpc.RPC, receivedFrom: PeerHandler) { - val peerSupportedExtensions = peerExtensionSupportMap[receivedFrom.peerId] - if (peerSupportedExtensions == null) { + val peerSupportedExtensions = + gossipExtensionsState.peerSupportedExtensions(receivedFrom.peerId) + + // TODO Revisit this logic as part of adding feature flags (https://github.com/libp2p/jvm-libp2p/issues/441) + + when { + msg.hasTestExtension() && checkPeerExtensionSupport( + peerSupportedExtensions, + Rpc.ControlExtensions::hasTestExtension + ) -> + processTestExtensionMessage(msg.testExtension, receivedFrom) + + msg.hasPartial() && checkPeerExtensionSupport( + peerSupportedExtensions, + Rpc.ControlExtensions::hasPartialMessages + ) -> + processPartialMessageExtension(msg.partial, receivedFrom) + } + } + + private fun checkPeerExtensionSupport( + peerSavedPreferences: Rpc.ControlExtensions?, + checkSupportFunction: (Rpc.ControlExtensions) -> Boolean + ): Boolean { + if (peerSavedPreferences == null) { + return false + } + + if (!checkSupportFunction.invoke(peerSavedPreferences)) { logger.trace( - "Ignoring extension messages from peer {} - did it send an extension control message?", - receivedFrom.peerId + "Ignoring extension messages from peer {} - did it send an control extensions message?", + peerSavedPreferences ) - } else { - when { - peerSupportedExtensions.hasTestExtension() && msg.hasTestExtension() -> - processTestExtensionMessage(msg.testExtension, receivedFrom) - - peerSupportedExtensions.hasPartialMessages() && msg.hasPartial() -> - processPartialMessageExtension(msg.partial, receivedFrom) - } + return false } + + return true } private fun processTestExtensionMessage( @@ -578,6 +602,8 @@ open class GossipRouter( fanout -= topic lastPublished -= topic } + + activePeers.forEach { sendControlExtensions(it) } } override fun unsubscribe(topic: Topic) { @@ -778,6 +804,33 @@ open class GossipRouter( send(peer, iDontWant) } + private fun sendControlExtensions(peer: PeerHandler) { + if (!this.protocol.supportsExtensions()) { + logger.trace( + "Protocol does not support extensions. Won't send control extensions message." + ) + return + } + + if (gossipExtensionsState.hasSentControlExtensionsTo(peer.peerId)) { + logger.trace( + "Already sent control extensions msg to peer {}. Won't send another one.", + peer.peerId + ) + return + } + + logger.trace("Sending control extensions message to peer {}", peer.peerId) + + pendingRpcParts.getQueue(peer).addControlExtensions( + Rpc.ControlExtensions.newBuilder() + .setTestExtension(true) + .setPartialMessages(true) + .build() + ) + gossipExtensionsState.registerControlExtensionMessageSentToPeers(peer.peerId) + } + data class AcceptRequestsWhitelistEntry(val whitelistedTill: Long, val messagesAccepted: Int = 0) { fun incrementMessageCount() = AcceptRequestsWhitelistEntry(whitelistedTill, messagesAccepted + 1) } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueue.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueue.kt index e90332589..32e5c908a 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueue.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueue.kt @@ -26,6 +26,9 @@ interface GossipRpcPartsQueue : RpcPartsQueue { * Gossip 1.1 variant */ fun addPrune(topic: Topic, backoffSeconds: Long, backoffPeers: List) + + // TODO Need to check if we should handle when control extension and extension messages could be separated by split (https://github.com/libp2p/jvm-libp2p/issues/440) + fun addControlExtensions(ctrlMessage: Rpc.ControlExtensions) } /** @@ -81,6 +84,12 @@ open class DefaultGossipRpcPartsQueue( } } + protected data class ControlExtensionPart(val ctrlExtension: Rpc.ControlExtensions) : AbstractPart { + override fun appendToBuilder(builder: Rpc.RPC.Builder) { + builder.controlBuilder.setExtensions(ctrlExtension) + } + } + override fun addIHave(messageId: MessageId, topic: Topic) { addPart(IHavePart(messageId, topic)) } @@ -101,6 +110,10 @@ open class DefaultGossipRpcPartsQueue( addPart(PrunePart(topic, backoffSeconds, backoffPeers)) } + override fun addControlExtensions(ctrlMessage: Rpc.ControlExtensions) { + addPart(ControlExtensionPart(ctrlMessage)) + } + override fun takeMerged(): List { val ret = mutableListOf() var partIdx = 0 diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsStateTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsStateTest.kt new file mode 100644 index 000000000..315c8dec3 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsStateTest.kt @@ -0,0 +1,358 @@ +package io.libp2p.pubsub.gossip + +import io.libp2p.core.PeerId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +class GossipExtensionsStateTest { + + private lateinit var extensionsState: GossipExtensionsState + private lateinit var peer1: PeerId + private lateinit var peer2: PeerId + private lateinit var peer3: PeerId + + @BeforeEach + fun setup() { + extensionsState = GossipExtensionsState() + peer1 = PeerId.random() + peer2 = PeerId.random() + peer3 = PeerId.random() + } + + @Test + fun `onControlExtensionsMessage() stores peer extensions support`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .setTestExtension(false) + .build() + + extensionsState.onControlExtensionsMessage(extension, peer1) + + val stored = extensionsState.peerSupportedExtensions(peer1) + assertThat(stored).isNotNull + assertThat(stored!!.partialMessages).isTrue() + assertThat(stored.testExtension).isFalse() + } + + @Test + fun `hasReceivedControlExtensionsFrom() returns true after receiving extensions`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + + extensionsState.onControlExtensionsMessage(extension, peer1) + + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isTrue() + } + + @Test + fun `hasReceivedControlExtensionsFrom() returns false for unknown peer`() { + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + } + + @Test + fun `peerSupportedExtensions() returns null for unknown peer`() { + val extensions = extensionsState.peerSupportedExtensions(peer1) + assertThat(extensions).isNull() + } + + /* + In practice, we should not receive more than one control message from the same peer on + the same connection, but if this ever happens, it makes sense to override the in-memory + config given it most likely has the most up-to-date data for that particular peer + */ + @Test + fun `onControlExtensionsMessage() overwrites previous extensions from same peer`() { + val extension1 = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .setTestExtension(false) + .build() + + val extension2 = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(false) + .setTestExtension(true) + .build() + + extensionsState.onControlExtensionsMessage(extension1, peer1) + extensionsState.onControlExtensionsMessage(extension2, peer1) + + val stored = extensionsState.peerSupportedExtensions(peer1) + assertThat(stored).isNotNull + assertThat(stored!!.partialMessages).isFalse() + assertThat(stored.testExtension).isTrue() + } + + @Test + fun `hasSentControlExtensionsTo() returns false for unknown peer`() { + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isFalse() + } + + @Test + fun `registerControlExtensionMessageSentToPeers() registers peer`() { + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isFalse() + + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + } + + @Test + fun `hasSentControlExtensionsTo() returns true after registration`() { + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + } + + @Test + fun `registerControlExtensionMessageSentToPeers() can register multiple peers`() { + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + extensionsState.registerControlExtensionMessageSentToPeers(peer2) + + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + assertThat(extensionsState.hasSentControlExtensionsTo(peer2)).isTrue() + assertThat(extensionsState.hasSentControlExtensionsTo(peer3)).isFalse() + } + + @Test + fun `sent and received extension tracking are independent`() { + // Register that we sent to peer1 + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + + // Receive from peer2 + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + extensionsState.onControlExtensionsMessage(extension, peer2) + + // Verify sent tracking + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + assertThat(extensionsState.hasSentControlExtensionsTo(peer2)).isFalse() + + // Verify received tracking + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer2)).isTrue() + } + + @Test + fun `peer can be in both sent and received tracking`() { + // Register that we sent to peer1 + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + + // Receive from peer1 + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + extensionsState.onControlExtensionsMessage(extension, peer1) + + // Both should be tracked + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isTrue() + } + + @Test + fun `tracks multiple peers with different extensions`() { + val extension1 = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .setTestExtension(false) + .build() + + val extension2 = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(false) + .setTestExtension(true) + .build() + + val extension3 = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .setTestExtension(true) + .build() + + extensionsState.onControlExtensionsMessage(extension1, peer1) + extensionsState.onControlExtensionsMessage(extension2, peer2) + extensionsState.onControlExtensionsMessage(extension3, peer3) + + // Verify each peer has correct extensions + val stored1 = extensionsState.peerSupportedExtensions(peer1) + assertThat(stored1!!.partialMessages).isTrue() + assertThat(stored1.testExtension).isFalse() + + val stored2 = extensionsState.peerSupportedExtensions(peer2) + assertThat(stored2!!.partialMessages).isFalse() + assertThat(stored2.testExtension).isTrue() + + val stored3 = extensionsState.peerSupportedExtensions(peer3) + assertThat(stored3!!.partialMessages).isTrue() + assertThat(stored3.testExtension).isTrue() + + // Verify all peers are tracked + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isTrue() + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer2)).isTrue() + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer3)).isTrue() + } + + @Test + fun `tracks many peers simultaneously`() { + val peers = (1..10).map { PeerId.random() } + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + + peers.forEach { peer -> + extensionsState.onControlExtensionsMessage(extension, peer) + } + + // Verify all peers are tracked + peers.forEach { peer -> + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer)).isTrue() + assertThat(extensionsState.peerSupportedExtensions(peer)).isNotNull + } + } + + @Test + fun `onPeerDisconnected() removes peer from received extensions map`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + + extensionsState.onControlExtensionsMessage(extension, peer1) + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isTrue() + + extensionsState.onPeerDisconnected(peer1) + + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + assertThat(extensionsState.peerSupportedExtensions(peer1)).isNull() + } + + @Test + fun `onPeerDisconnected() handles unknown peer gracefully`() { + // Should not throw exception for unknown peer + extensionsState.onPeerDisconnected(peer1) + + // State should remain empty + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + assertThat(extensionsState.peerSupportedExtensions(peer1)).isNull() + } + + @Test + fun `onPeerDisconnected() only removes specified peer`() { + val extension1 = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + + val extension2 = Rpc.ControlExtensions.newBuilder() + .setTestExtension(true) + .build() + + extensionsState.onControlExtensionsMessage(extension1, peer1) + extensionsState.onControlExtensionsMessage(extension2, peer2) + + // Disconnect peer1 + extensionsState.onPeerDisconnected(peer1) + + // peer1 should be removed + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + assertThat(extensionsState.peerSupportedExtensions(peer1)).isNull() + + // peer2 should remain + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer2)).isTrue() + assertThat(extensionsState.peerSupportedExtensions(peer2)).isNotNull + assertThat(extensionsState.peerSupportedExtensions(peer2)!!.testExtension).isTrue() + } + + @Test + fun `multiple disconnects and reconnects work correctly`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + + // Connect + extensionsState.onControlExtensionsMessage(extension, peer1) + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isTrue() + + // Disconnect + extensionsState.onPeerDisconnected(peer1) + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + + // Reconnect with different extensions + val newExtension = Rpc.ControlExtensions.newBuilder() + .setTestExtension(true) + .build() + extensionsState.onControlExtensionsMessage(newExtension, peer1) + + val stored = extensionsState.peerSupportedExtensions(peer1) + assertThat(stored).isNotNull + assertThat(stored!!.hasPartialMessages()).isFalse() + assertThat(stored.testExtension).isTrue() + } + + @Test + fun `onPeerDisconnected() removes peer from sent extensions list`() { + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + + extensionsState.onPeerDisconnected(peer1) + + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isFalse() + } + + @Test + fun `onPeerDisconnected() removes peer from both sent and received tracking`() { + // Register sent + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + + // Register received + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + extensionsState.onControlExtensionsMessage(extension, peer1) + + // Verify both tracked + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isTrue() + + // Disconnect + extensionsState.onPeerDisconnected(peer1) + + // Both should be removed + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isFalse() + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + assertThat(extensionsState.peerSupportedExtensions(peer1)).isNull() + } + + @Test + fun `onPeerDisconnected() only removes specified peer from sent list`() { + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + extensionsState.registerControlExtensionMessageSentToPeers(peer2) + + extensionsState.onPeerDisconnected(peer1) + + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isFalse() + assertThat(extensionsState.hasSentControlExtensionsTo(peer2)).isTrue() + } + + @Test + fun `reconnecting peer can have sent extension registered again`() { + // First connection - register sent + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + + // Disconnect + extensionsState.onPeerDisconnected(peer1) + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isFalse() + + // Reconnect - register sent again + extensionsState.registerControlExtensionMessageSentToPeers(peer1) + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isTrue() + } + + @Test + fun `querying empty state returns expected values`() { + extensionsState = GossipExtensionsState() + assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer1)).isFalse() + assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isFalse() + assertThat(extensionsState.peerSupportedExtensions(peer1)).isNull() + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueueTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueueTest.kt index 5b6b35e55..e978877d2 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueueTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueueTest.kt @@ -306,4 +306,191 @@ class GossipRpcPartsQueueTest { .addMessageIDs("2222".toWBytes().toProtobuf()).build(), ) } + + @Test + fun `addControlExtensions() sets testExtension flag in control message`() { + val partsQueue = TestGossipQueue(gossipParamsNoLimits) + + val extension = Rpc.ControlExtensions.newBuilder() + .setTestExtension(true) + .build() + + partsQueue.addControlExtensions(extension) + + val res = partsQueue.takeMerged().first() + + assertThat(res.hasControl()).isTrue() + assertThat(res.control.hasExtensions()).isTrue() + assertThat(res.control.extensions.testExtension).isTrue() + } + + @Test + fun `addControlExtensions() sets partialMessages flag in control message`() { + val partsQueue = TestGossipQueue(gossipParamsNoLimits) + + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + + partsQueue.addControlExtensions(extension) + + val res = partsQueue.takeMerged().first() + + assertThat(res.hasControl()).isTrue() + assertThat(res.control.hasExtensions()).isTrue() + assertThat(res.control.extensions.partialMessages).isTrue() + } + + @Test + fun `addControlExtensions() sets all extension flags`() { + val partsQueue = TestGossipQueue(gossipParamsNoLimits) + + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .setTestExtension(true) + .build() + + partsQueue.addControlExtensions(extension) + + val res = partsQueue.takeMerged().first() + + assertThat(res.hasControl()).isTrue() + assertThat(res.control.hasExtensions()).isTrue() + assertThat(res.control.extensions.partialMessages).isTrue() + assertThat(res.control.extensions.testExtension).isTrue() + } + + @Test + fun `control extensions message works with other control messages`() { + val partsQueue = TestGossipQueue(gossipParamsNoLimits) + + // Add various control messages + partsQueue.addIHave(byteArrayOf(1).toWBytes(), "topic1") + partsQueue.addIWant(byteArrayOf(2).toWBytes()) + partsQueue.addGraft("topic2") + partsQueue.addPrune("topic3") + + // Add extension + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + partsQueue.addControlExtensions(extension) + + val res = partsQueue.takeMerged().first() + + // Verify all control messages are present + assertThat(res.hasControl()).isTrue() + assertThat(res.control.ihaveList).hasSize(1) + assertThat(res.control.iwantList).hasSize(1) + assertThat(res.control.graftList).hasSize(1) + assertThat(res.control.pruneList).hasSize(1) + + // Verify extension is present + assertThat(res.control.hasExtensions()).isTrue() + assertThat(res.control.extensions.partialMessages).isTrue() + } + + @Test + fun `control extensions message with subscriptions and publishes`() { + val partsQueue = TestGossipQueue(gossipParamsNoLimits) + + partsQueue.addSubscribe("topic1") + partsQueue.addPublish(createRpcMessage("topic1", "data1")) + + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + partsQueue.addControlExtensions(extension) + + val res = partsQueue.takeMerged().first() + + // Verify subscriptions and publishes + assertThat(res.subscriptionsList).hasSize(1) + assertThat(res.publishList).hasSize(1) + + // Verify extension + assertThat(res.control.hasExtensions()).isTrue() + assertThat(res.control.extensions.partialMessages).isTrue() + } + + @Test + fun `control extensions message works with message splitting`() { + val partsQueue = TestGossipQueue(gossipParamsWithLimits) + + // Add enough messages to force splitting + (1..20).forEach { + partsQueue.addPublish(createRpcMessage("topic-$it", "data")) + } + + // Add extension + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + partsQueue.addControlExtensions(extension) + + val merged = partsQueue.takeMerged() + + // Should be split into multiple RPCs due to maxPublishedMessages limit + assertThat(merged.size).isGreaterThan(1) + + // Extension should be in the last RPC (since it's added last) + val lastRpc = merged.last() + assertThat(lastRpc.hasControl()).isTrue() + assertThat(lastRpc.control.hasExtensions()).isTrue() + assertThat(lastRpc.control.extensions.partialMessages).isTrue() + } + + @Test + fun `multiple control extensions messages - last one wins`() { + val partsQueue = TestGossipQueue(gossipParamsNoLimits) + + // Add first extension + val extension1 = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .setTestExtension(false) + .build() + partsQueue.addControlExtensions(extension1) + + // Add second extension (should overwrite first) + val extension2 = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(false) + .setTestExtension(true) + .build() + partsQueue.addControlExtensions(extension2) + + val res = partsQueue.takeMerged().first() + + // Verify only the last extension is present + assertThat(res.control.hasExtensions()).isTrue() + // Note: false flags may or may not be serialized depending on protobuf default behavior + // But testExtension should definitely be true + assertThat(res.control.extensions.testExtension).isTrue() + } + + @Test + fun `control extensions message does not count toward limits but may be split`() { + val partsQueue = TestGossipQueue(gossipParamsWithLimits) + + // Add exactly maxPublishedMessages messages + (1..maxPublishedMessages).forEach { + partsQueue.addPublish(createRpcMessage("topic-$it", "data")) + } + + // Add extension + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + partsQueue.addControlExtensions(extension) + + val merged = partsQueue.takeMerged() + + // Extension doesn't count toward limits, but it may end up in a separate RPC + // if it comes after parts that exhaust a limit + assertThat(merged).hasSize(2) + assertThat(merged[0].publishList).hasSize(maxPublishedMessages) + + // Extension should be in the second RPC + assertThat(merged[1].control.hasExtensions()).isTrue() + assertThat(merged[1].control.extensions.partialMessages).isTrue() + } } diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt index e9c97ce85..391e7a762 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt @@ -2,11 +2,16 @@ package io.libp2p.pubsub.gossip.extensions import io.libp2p.pubsub.PubsubProtocol import io.libp2p.pubsub.gossip.GossipTestsBase +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource import pubsub.pb.Rpc import java.util.concurrent.TimeoutException +private const val DEFAULT_WAIT_TIMEOUT_IN_MILLIS = 500L + class GossipExtensionsMessageHandlingTest : GossipTestsBase() { @Test @@ -15,87 +20,195 @@ class GossipExtensionsMessageHandlingTest : GossipTestsBase() { protocol = PubsubProtocol.Gossip_V_1_2 ) - val rpcMessageWithControlExtensionAndTestExtensionMessages = Rpc.RPC.newBuilder() - .setControl( - Rpc.ControlMessage.newBuilder() - .setExtensions(Rpc.ControlExtensions.newBuilder().setTestExtension(true)) - .build() - ) - .setTestExtension(Rpc.TestExtension.newBuilder().build()) - .build() - test.mockRouter.sendToSingle(rpcMessageWithControlExtensionAndTestExtensionMessages) - + test.mockRouter.sendToSingle(rpcMsgWithCtrlExtensionsAndTestExtension) assertNoResponseFromTestExtension(test) } @Test - fun `extension messages sent to peer prior to sending extension control messages are ignored`() { + fun `extension messages sent to peer prior to sending control extensions messages are ignored`() { val test = TwoRoutersTest( protocol = PubsubProtocol.Gossip_V_1_3 ) - val rpcMessageWithTestExtension = - Rpc.RPC.newBuilder().setTestExtension(testExtensionMessage).build() test.mockRouter.sendToSingle(rpcMessageWithTestExtension) - assertNoResponseFromTestExtension(test) } @Test - fun `extension message flow with extension control message before actual extension message`() { + fun `extension message flow with control extensions message before actual extension message`() { val test = TwoRoutersTest( protocol = PubsubProtocol.Gossip_V_1_3 ) - val rpcMessageWithControl = Rpc.RPC.newBuilder().setControl( - Rpc.ControlMessage.newBuilder().setExtensions(controlExtensionMessage()) - ).build() - test.mockRouter.sendToSingle(rpcMessageWithControl) + test.mockRouter.sendToSingle(rpcMessageWithControlExtensions) + assertThat(test.gossipRouter.gossipExtensionsState.peerSupportedExtensions(test.router2.peerId)).isEqualTo( + rpcMessageWithControlExtensions.control.extensions + ) - val rpcMessageWithTestExtension = - Rpc.RPC.newBuilder().setTestExtension(testExtensionMessage).build() test.mockRouter.sendToSingle(rpcMessageWithTestExtension) + test.mockRouter.waitForMessage { it.hasTestExtension() } + } + + @Test + fun `extension message flow with control extensions and extension message in the same rpc message`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3 + ) + + test.mockRouter.sendToSingle(rpcMsgWithCtrlExtensionsAndTestExtension) + test.mockRouter.waitForMessage { it.hasTestExtension() } + } + + @Test + fun `remove peer control extensions map when disconnecting`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3 + ) + + test.mockRouter.sendToSingle(rpcMsgWithCtrlExtensionsAndTestExtension) + + assertThat(test.gossipRouter.gossipExtensionsState.peerSupportedExtensions(test.router2.peerId)).isEqualTo( + rpcMsgWithCtrlExtensionsAndTestExtension.control.extensions + ) test.mockRouter.waitForMessage { it.hasTestExtension() } + + // Successfully registered peer2 extensions support + + assertThat(test.gossipRouter.gossipExtensionsState.peerSupportedExtensions(test.router2.peerId)).isNotNull() + + test.connection.disconnect() + + // After disconnecting removes peer2 from extensions support map + assertThat(test.gossipRouter.gossipExtensionsState.peerSupportedExtensions(test.router2.peerId)).isNull() + } + + @ParameterizedTest + @MethodSource("protocolVersionsWithExtensionSupport") + fun `control extension message sent to peer on connection with extension support`(protocol: PubsubProtocol) { + val test = TwoRoutersTest(protocol = protocol) + + val receivedMessage = test.mockRouter.waitForMessage( + { it.hasControl() && it.control.hasExtensions() }, + DEFAULT_WAIT_TIMEOUT_IN_MILLIS + ) + + assertThat(receivedMessage.control.extensions.partialMessages).isTrue() + assertThat(receivedMessage.control.extensions.testExtension).isTrue() + } + + @ParameterizedTest + @MethodSource("protocolVersionsWithoutExtensionSupport") + fun `control extension message not sent to peer on connection without extension support`(protocol: PubsubProtocol) { + val test = TwoRoutersTest(protocol = protocol) + + // Should not receive control extension message on versions without extension support + assertThrows { + test.mockRouter.waitForMessage( + { it.hasControl() && it.control.hasExtensions() }, + DEFAULT_WAIT_TIMEOUT_IN_MILLIS + ) + } + } + + @Test + fun `control extension message contains all supported extensions flags`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3 + ) + + val receivedMessage = test.mockRouter.waitForMessage( + { it.hasControl() && it.control.hasExtensions() }, + 2000L + ) + + val extensions = receivedMessage.control.extensions + + // Verify both extension flags are set + assertThat(extensions.hasPartialMessages()).isTrue() + assertThat(extensions.partialMessages).isTrue() + assertThat(extensions.hasTestExtension()).isTrue() + assertThat(extensions.testExtension).isTrue() } @Test - fun `extension message flow with extension control and extension message in the same rpc message`() { + fun `extension state tracks that we sent control extension to peer`() { val test = TwoRoutersTest( protocol = PubsubProtocol.Gossip_V_1_3 ) - val rpcMessageWithControlExtensionAndTestExtensionMessages = Rpc.RPC.newBuilder() + // Wait for control extension message to be sent + test.mockRouter.waitForMessage( + { it.hasControl() && it.control.hasExtensions() }, + DEFAULT_WAIT_TIMEOUT_IN_MILLIS + ) + + // Should be tracked in state + assertThat(test.gossipRouter.gossipExtensionsState.hasSentControlExtensionsTo(test.router2.peerId)).isTrue() + } + + @Test + fun `control extension sent state cleared on peer disconnect`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3 + ) + + // Wait for control extension message + test.mockRouter.waitForMessage( + { it.hasControl() && it.control.hasExtensions() }, + DEFAULT_WAIT_TIMEOUT_IN_MILLIS + ) + + // Verify it's tracked + assertThat(test.gossipRouter.gossipExtensionsState.hasSentControlExtensionsTo(test.router2.peerId)).isTrue() + + // Disconnect + test.connection.disconnect() + + // Should be cleared from sent tracking + assertThat(test.gossipRouter.gossipExtensionsState.hasSentControlExtensionsTo(test.router2.peerId)).isFalse() + } + + companion object { + @JvmStatic + fun protocolVersionsWithExtensionSupport() = listOf( + PubsubProtocol.Gossip_V_1_3 + ) + + @JvmStatic + fun protocolVersionsWithoutExtensionSupport() = listOf( + PubsubProtocol.Gossip_V_1_1, + PubsubProtocol.Gossip_V_1_2 + ) + + val testExtensionMessage: Rpc.TestExtension = Rpc.TestExtension.newBuilder().build() + + val rpcMessageWithControlExtensions = Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().setExtensions(controlExtensionMessage()) + ).build()!! + + val rpcMessageWithTestExtension = + Rpc.RPC.newBuilder().setTestExtension(testExtensionMessage).build()!! + + // An RPC message with both ControlExtensions and TestExtension message (test extension enabled on control) + val rpcMsgWithCtrlExtensionsAndTestExtension = Rpc.RPC.newBuilder() .setControl( Rpc.ControlMessage.newBuilder() .setExtensions(Rpc.ControlExtensions.newBuilder().setTestExtension(true)) .build() ) .setTestExtension(Rpc.TestExtension.newBuilder().build()) - .build() - test.mockRouter.sendToSingle(rpcMessageWithControlExtensionAndTestExtensionMessages) + .build()!! - test.mockRouter.waitForMessage { it.hasTestExtension() } - } - - companion object { - val testExtensionControlEnabledMessage: Rpc.RPC = Rpc.RPC.newBuilder().setControl( - Rpc.ControlMessage.newBuilder() - .setExtensions(Rpc.ControlExtensions.newBuilder().setTestExtension(true).build()) - .build() - ).build() - - fun controlExtensionMessage(testExtensionEnabled: Boolean = false): Rpc.ControlExtensions { + fun controlExtensionMessage(testExtensionEnabled: Boolean = true): Rpc.ControlExtensions { return Rpc.ControlExtensions.newBuilder().setTestExtension(testExtensionEnabled).build() } - val testExtensionMessage: Rpc.TestExtension = Rpc.TestExtension.newBuilder().build() - fun assertNoResponseFromTestExtension(test: TwoRoutersTest) { assertThrows { test.mockRouter.waitForMessage( { it.hasTestExtension() }, - 500L + DEFAULT_WAIT_TIMEOUT_IN_MILLIS ) } } From 00e7419b402c32498981c9b519868a35b36e4493 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Fri, 20 Feb 2026 08:40:05 +1300 Subject: [PATCH 20/30] Gossip Extensions feature flag (#443) --- .../kotlin/io/libp2p/pubsub/AbstractRouter.kt | 4 +- .../pubsub/gossip/GossipExtensionsState.kt | 20 ++- .../io/libp2p/pubsub/gossip/GossipRouter.kt | 75 ++++----- .../gossip/builders/GossipRouterBuilder.kt | 7 +- .../gossip/GossipExtensionsStateTest.kt | 150 +++++++++++++++++- .../pubsub/gossip/GossipRouterBuilderTest.kt | 29 ++++ .../libp2p/pubsub/gossip/GossipTestsBase.kt | 14 +- .../GossipExtensionsMessageHandlingTest.kt | 30 +++- .../simulate/gossip/router/SimGossipRouter.kt | 1 + 9 files changed, 281 insertions(+), 49 deletions(-) create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt index 66f7e4ab0..a72b93ccf 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt @@ -185,9 +185,7 @@ abstract class AbstractRouter( processControl(msg.control, peer) } - // TODO we need to handle the existence of extension messages more generically (https://github.com/libp2p/jvm-libp2p/issues/441) - - if (protocol.supportsExtensions() && (msg.hasTestExtension() || msg.hasPartial())) { + if (protocol.supportsExtensions()) { processExtensions(msg, peer) } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt index 24daf6ade..94abbd4a7 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt @@ -3,7 +3,17 @@ package io.libp2p.pubsub.gossip import io.libp2p.core.PeerId import pubsub.pb.Rpc -class GossipExtensionsState { +data class GossipExtensionsConfig( + val partialMessagesEnabled: Boolean = false, + val testExtensionEnabled: Boolean = false +) + +class GossipExtensionsState(gossipExtensionsConfig: GossipExtensionsConfig? = null) { + + val localExtensionSupport: Rpc.ControlExtensions = Rpc.ControlExtensions.newBuilder() + .setTestExtension(gossipExtensionsConfig?.testExtensionEnabled ?: false) + .setPartialMessages(gossipExtensionsConfig?.partialMessagesEnabled ?: false) + .build() /* Tracks the peers that we have already sent a control extensions message @@ -35,4 +45,12 @@ class GossipExtensionsState { fun hasSentControlExtensionsTo(peer: PeerId) = outgoingControlExtensionsMsgPeers.contains(peer) + + fun testExtensionsEnabled() = localExtensionSupport.testExtension + fun peerSupportsTestExtensions(peerId: PeerId) = + peerExtensionSupportMap[peerId]?.testExtension == true + + fun partialMessagesEnabled() = localExtensionSupport.partialMessages + fun peerSupportsPartialMessages(peerId: PeerId) = + peerExtensionSupportMap[peerId]?.partialMessages == true } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt index 1bb463436..5ba315d73 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt @@ -82,6 +82,7 @@ open class GossipRouter( val name: String, val mCache: MCache, val score: GossipScore, + val gossipExtensionsConfig: GossipExtensionsConfig = GossipExtensionsConfig(), subscriptionTopicSubscriptionFilter: TopicSubscriptionFilter, protocol: PubsubProtocol, @@ -132,7 +133,7 @@ open class GossipRouter( private val acceptRequestsWhitelist = mutableMapOf() override val pendingRpcParts = PendingRpcPartsMap { DefaultGossipRpcPartsQueue(params) } - val gossipExtensionsState = GossipExtensionsState() + val gossipExtensionsState = GossipExtensionsState(gossipExtensionsConfig) private fun setBackOff(peer: PeerHandler, topic: Topic) = setBackOff(peer, topic, params.pruneBackoff.toMillis()) private fun setBackOff(peer: PeerHandler, topic: Topic, delay: Long) { @@ -413,43 +414,47 @@ open class GossipRouter( } override fun processExtensions(msg: Rpc.RPC, receivedFrom: PeerHandler) { - val peerSupportedExtensions = - gossipExtensionsState.peerSupportedExtensions(receivedFrom.peerId) + when { + msg.hasTestExtension() -> { + if (!gossipExtensionsState.testExtensionsEnabled()) { + logger.trace( + "Ignoring test extension message from peer {} - test extension disabled", + msg + ) + return + } - // TODO Revisit this logic as part of adding feature flags (https://github.com/libp2p/jvm-libp2p/issues/441) + if (!gossipExtensionsState.peerSupportsTestExtensions(receivedFrom.peerId)) { + logger.trace( + "Ignoring test extension message from peer {} - did peer send ControlExtensions prior?", + msg + ) + return + } - when { - msg.hasTestExtension() && checkPeerExtensionSupport( - peerSupportedExtensions, - Rpc.ControlExtensions::hasTestExtension - ) -> processTestExtensionMessage(msg.testExtension, receivedFrom) + } - msg.hasPartial() && checkPeerExtensionSupport( - peerSupportedExtensions, - Rpc.ControlExtensions::hasPartialMessages - ) -> - processPartialMessageExtension(msg.partial, receivedFrom) - } - } + msg.hasPartial() -> { + if (!gossipExtensionsState.partialMessagesEnabled()) { + logger.trace( + "Ignoring partial messages message from peer {} - partial messages extension disabled", + msg + ) + return + } - private fun checkPeerExtensionSupport( - peerSavedPreferences: Rpc.ControlExtensions?, - checkSupportFunction: (Rpc.ControlExtensions) -> Boolean - ): Boolean { - if (peerSavedPreferences == null) { - return false - } + if (!gossipExtensionsState.peerSupportsPartialMessages(receivedFrom.peerId)) { + logger.trace( + "Ignoring partial messages message from peer {} - did peer send ControlExtensions prior?", + msg + ) + return + } - if (!checkSupportFunction.invoke(peerSavedPreferences)) { - logger.trace( - "Ignoring extension messages from peer {} - did it send an control extensions message?", - peerSavedPreferences - ) - return false + processPartialMessageExtension(msg.partial, receivedFrom) + } } - - return true } private fun processTestExtensionMessage( @@ -822,12 +827,8 @@ open class GossipRouter( logger.trace("Sending control extensions message to peer {}", peer.peerId) - pendingRpcParts.getQueue(peer).addControlExtensions( - Rpc.ControlExtensions.newBuilder() - .setTestExtension(true) - .setPartialMessages(true) - .build() - ) + pendingRpcParts.getQueue(peer) + .addControlExtensions(gossipExtensionsState.localExtensionSupport) gossipExtensionsState.registerControlExtensionMessageSentToPeers(peer.peerId) } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt index 5c783ce5f..f694a4e93 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt @@ -38,7 +38,9 @@ open class GossipRouterBuilder( eventsSubscriber(gossipScore) gossipScore }, - val gossipRouterEventListeners: MutableList = mutableListOf() + val gossipRouterEventListeners: MutableList = mutableListOf(), + + var gossipExtensionsConfig: GossipExtensionsConfig = GossipExtensionsConfig() ) { var seenCache: SeenCache> by lazyVar { TTLSeenCache(SimpleSeenCache(), params.seenTTL, currentTimeSuppluer) } @@ -62,7 +64,8 @@ open class GossipRouterBuilder( executor = scheduledAsyncExecutor, messageFactory = messageFactory, seenMessages = seenCache, - messageValidator = messageValidator + messageValidator = messageValidator, + gossipExtensionsConfig = gossipExtensionsConfig ) router.eventBroadcaster.listeners += gossipRouterEventListeners diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsStateTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsStateTest.kt index 315c8dec3..93f7aa8b9 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsStateTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsStateTest.kt @@ -4,7 +4,11 @@ import io.libp2p.core.PeerId import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import pubsub.pb.Rpc +import java.util.stream.Stream class GossipExtensionsStateTest { @@ -15,7 +19,9 @@ class GossipExtensionsStateTest { @BeforeEach fun setup() { - extensionsState = GossipExtensionsState() + extensionsState = GossipExtensionsState( + gossipExtensionsConfig = GossipExtensionsConfig(testExtensionEnabled = true) + ) peer1 = PeerId.random() peer2 = PeerId.random() peer3 = PeerId.random() @@ -193,6 +199,23 @@ class GossipExtensionsStateTest { assertThat(extensionsState.hasReceivedControlExtensionsFrom(peer3)).isTrue() } + @Test + fun `tracks different peer extension support for partial messages`() { + val withPartial = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + + val withoutPartial = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(false) + .build() + + extensionsState.onControlExtensionsMessage(withPartial, peer1) + extensionsState.onControlExtensionsMessage(withoutPartial, peer2) + + assertThat(extensionsState.peerSupportsPartialMessages(peer1)).isTrue() + assertThat(extensionsState.peerSupportsPartialMessages(peer2)).isFalse() + } + @Test fun `tracks many peers simultaneously`() { val peers = (1..10).map { PeerId.random() } @@ -355,4 +378,129 @@ class GossipExtensionsStateTest { assertThat(extensionsState.hasSentControlExtensionsTo(peer1)).isFalse() assertThat(extensionsState.peerSupportedExtensions(peer1)).isNull() } + + @Test + fun `peerSupportsTestExtensions returns true when peer has extension`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setTestExtension(true) + .build() + + extensionsState.onControlExtensionsMessage(extension, peer1) + + assertThat(extensionsState.peerSupportsTestExtensions(peer1)).isTrue() + } + + @Test + fun `peerSupportsTestExtensions returns false when peer doesn't have extension`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setTestExtension(false) + .setPartialMessages(true) + .build() + + extensionsState.onControlExtensionsMessage(extension, peer1) + + assertThat(extensionsState.peerSupportsTestExtensions(peer1)).isFalse() + } + + @Test + fun `peerSupportsTestExtensions returns false for unknown peer`() { + assertThat(extensionsState.peerSupportsTestExtensions(peer1)).isFalse() + } + + @Test + fun `peerSupportsPartialMessages returns true when peer has extension`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(true) + .build() + + extensionsState.onControlExtensionsMessage(extension, peer1) + + assertThat(extensionsState.peerSupportsPartialMessages(peer1)).isTrue() + } + + @Test + fun `peerSupportsPartialMessages returns false when peer doesn't have extension`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setPartialMessages(false) + .setTestExtension(true) + .build() + + extensionsState.onControlExtensionsMessage(extension, peer1) + + assertThat(extensionsState.peerSupportsPartialMessages(peer1)).isFalse() + } + + @Test + fun `peerSupportsPartialMessages returns false for unknown peer`() { + assertThat(extensionsState.peerSupportsPartialMessages(peer1)).isFalse() + } + + @Test + fun `default config has both extensions disabled`() { + val state = GossipExtensionsState() + + assertThat(state.testExtensionsEnabled()).isFalse() + assertThat(state.partialMessagesEnabled()).isFalse() + } + + @ParameterizedTest + @MethodSource("gossipExtensionConfigParams") + fun `config flags combinations for all extensions`( + description: String, + testExtensionsEnabled: Boolean, + partialMessagesEnabled: Boolean + ) { + val config = GossipExtensionsConfig( + testExtensionEnabled = testExtensionsEnabled, + partialMessagesEnabled = partialMessagesEnabled + ) + + assertThat(config.testExtensionEnabled).isEqualTo(testExtensionsEnabled) + .withFailMessage("expected $description") + assertThat(config.partialMessagesEnabled).isEqualTo(partialMessagesEnabled) + .withFailMessage("expected $description") + } + + companion object { + @JvmStatic + fun gossipExtensionConfigParams(): Stream { + return Stream.of( + Arguments.of("both extensions enabled", true, true), + Arguments.of("only test extensions enabled", false, true), + Arguments.of("only partial messages enabled", true, false), + Arguments.of("both extensions disabled", false, false) + ) + } + } + + @Test + fun `localExtensionSupport field reflects config`() { + val state = GossipExtensionsState( + GossipExtensionsConfig( + testExtensionEnabled = true, + partialMessagesEnabled = false + ) + ) + + val localSupport = state.localExtensionSupport + assertThat(localSupport.testExtension).isTrue() + assertThat(localSupport.partialMessages).isFalse() + } + + @Test + fun `peer extension support cleared on disconnect`() { + val extension = Rpc.ControlExtensions.newBuilder() + .setTestExtension(true) + .setPartialMessages(true) + .build() + + extensionsState.onControlExtensionsMessage(extension, peer1) + assertThat(extensionsState.peerSupportsTestExtensions(peer1)).isTrue() + assertThat(extensionsState.peerSupportsPartialMessages(peer1)).isTrue() + + extensionsState.onPeerDisconnected(peer1) + + assertThat(extensionsState.peerSupportsTestExtensions(peer1)).isFalse() + assertThat(extensionsState.peerSupportsPartialMessages(peer1)).isFalse() + } } diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt new file mode 100644 index 000000000..a17277832 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt @@ -0,0 +1,29 @@ +package io.libp2p.pubsub.gossip + +import io.libp2p.pubsub.gossip.builders.GossipRouterBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class GossipRouterBuilderTest { + + @Test + fun `builds GossipRouter with both extensions disabled by default`() { + val router = GossipRouterBuilder().build() + + assertThat(router.gossipExtensionsState.testExtensionsEnabled()).isFalse() + assertThat(router.gossipExtensionsState.partialMessagesEnabled()).isFalse() + } + + @Test + fun `localExtensionSupport reflects config in built router`() { + val config = GossipExtensionsConfig( + testExtensionEnabled = true, + partialMessagesEnabled = false + ) + val router = GossipRouterBuilder(gossipExtensionsConfig = config).build() + + val localSupport = router.gossipExtensionsState.localExtensionSupport + assertThat(localSupport.testExtension).isTrue() + assertThat(localSupport.partialMessages).isFalse() + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt index ecc912256..f25d7be4b 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt @@ -62,10 +62,20 @@ abstract class GossipTestsBase { val coreParams: GossipParams = GossipParams(), val scoreParams: GossipScoreParams = GossipScoreParams(), val mockRouterFactory: DeterministicFuzzRouterFactory = createMockFuzzRouterFactory(), - val protocol: PubsubProtocol = PubsubProtocol.Gossip_V_1_1 + val protocol: PubsubProtocol = PubsubProtocol.Gossip_V_1_1, + val gossipExtensionsConfig: GossipExtensionsConfig = GossipExtensionsConfig( + testExtensionEnabled = true + ) ) { val fuzz = DeterministicFuzz() - val gossipRouterBuilderFactory = { GossipRouterBuilder(protocol = protocol, params = coreParams, scoreParams = scoreParams) } + val gossipRouterBuilderFactory = { + GossipRouterBuilder( + protocol = protocol, + params = coreParams, + scoreParams = scoreParams, + gossipExtensionsConfig = gossipExtensionsConfig + ) + } val router1 = fuzz.createTestRouter(createGossipFuzzRouterFactory(gossipRouterBuilderFactory)) val router2 = fuzz.createTestRouter(mockRouterFactory) val gossipRouter = router1.router as GossipRouter diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt index 391e7a762..64e4f5fc9 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt @@ -1,6 +1,7 @@ package io.libp2p.pubsub.gossip.extensions import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.gossip.GossipExtensionsConfig import io.libp2p.pubsub.gossip.GossipTestsBase import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -93,13 +94,14 @@ class GossipExtensionsMessageHandlingTest : GossipTestsBase() { DEFAULT_WAIT_TIMEOUT_IN_MILLIS ) - assertThat(receivedMessage.control.extensions.partialMessages).isTrue() assertThat(receivedMessage.control.extensions.testExtension).isTrue() } @ParameterizedTest @MethodSource("protocolVersionsWithoutExtensionSupport") - fun `control extension message not sent to peer on connection without extension support`(protocol: PubsubProtocol) { + fun `control extension message not sent to peer on connection without extension support`( + protocol: PubsubProtocol + ) { val test = TwoRoutersTest(protocol = protocol) // Should not receive control extension message on versions without extension support @@ -111,10 +113,32 @@ class GossipExtensionsMessageHandlingTest : GossipTestsBase() { } } + @Test + fun `local peer ignores test extension messages when they are disabled in config`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + gossipExtensionsConfig = GossipExtensionsConfig( + testExtensionEnabled = false + ) + ) + + test.mockRouter.sendToSingle(rpcMsgWithCtrlExtensionsAndTestExtension) + assertThrows { + test.mockRouter.waitForMessage( + { it.hasTestExtension() }, + DEFAULT_WAIT_TIMEOUT_IN_MILLIS + ) + } + } + @Test fun `control extension message contains all supported extensions flags`() { val test = TwoRoutersTest( - protocol = PubsubProtocol.Gossip_V_1_3 + protocol = PubsubProtocol.Gossip_V_1_3, + gossipExtensionsConfig = GossipExtensionsConfig( + testExtensionEnabled = true, + partialMessagesEnabled = true + ) ) val receivedMessage = test.mockRouter.waitForMessage( diff --git a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouter.kt b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouter.kt index b6f830f91..2acc1569d 100644 --- a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouter.kt +++ b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouter.kt @@ -33,6 +33,7 @@ class SimGossipRouter( name, mCache, score, + gossipExtensionsConfig = GossipExtensionsConfig(), subscriptionTopicSubscriptionFilter, protocol, executor, From 77f76256ebfe827de10d334a1a404031d3554d0c Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Wed, 25 Feb 2026 17:13:48 +1300 Subject: [PATCH 21/30] Added peer penalties logic when sending multiple ControlExtensions message (#450) --- .../pubsub/gossip/GossipExtensionsState.kt | 8 ++++ .../io/libp2p/pubsub/gossip/GossipRouter.kt | 2 +- .../gossip/builders/GossipRouterBuilder.kt | 29 +++++++++---- .../pubsub/gossip/GossipRouterBuilderTest.kt | 26 +++++++++--- .../libp2p/pubsub/gossip/GossipTestsBase.kt | 7 ++-- .../GossipExtensionsMessageHandlingTest.kt | 41 +++++++++++++++---- .../io/libp2p/pubsub/DeterministicFuzz.kt | 2 +- .../simulate/gossip/GossipSimNetwork.kt | 2 +- .../libp2p/simulate/gossip/GossipSimPeer.kt | 2 +- .../gossip/router/SimGossipRouterBuilder.kt | 4 +- 10 files changed, 92 insertions(+), 31 deletions(-) diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt index 94abbd4a7..970f61d86 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipExtensionsState.kt @@ -3,6 +3,14 @@ package io.libp2p.pubsub.gossip import io.libp2p.core.PeerId import pubsub.pb.Rpc +enum class GossipExtension { + // Canonical extensions + PARTIAL_MESSAGES, + + // Non-canonical extensions + TEST_EXTENSION +} + data class GossipExtensionsConfig( val partialMessagesEnabled: Boolean = false, val testExtensionEnabled: Boolean = false diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt index 5ba315d73..bdfe69055 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt @@ -402,11 +402,11 @@ open class GossipRouter( logger.trace("Received control extension {}", ctrlExtensions.toString()) if (gossipExtensionsState.hasReceivedControlExtensionsFrom(receivedFrom.peerId)) { - // TODO Should disconnect peers that send control extension multiple times (https://github.com/libp2p/jvm-libp2p/issues/437) logger.trace( "Received another control extension message from peer {}", receivedFrom.peerId ) + notifyRouterMisbehavior(receivedFrom, 10) return } else { gossipExtensionsState.onControlExtensionsMessage(ctrlExtensions, receivedFrom.peerId) diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt index f694a4e93..214d4b06d 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt @@ -24,7 +24,7 @@ open class GossipRouterBuilder( var scheduledAsyncExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor( ThreadFactoryBuilder().setDaemon(true).setNameFormat("GossipRouter-event-thread-%d").build() ), - var currentTimeSuppluer: CurrentTimeSupplier = { System.currentTimeMillis() }, + var currentTimeSupplier: CurrentTimeSupplier = { System.currentTimeMillis() }, var random: Random = Random(), var messageFactory: PubsubMessageFactory = { DefaultPubsubMessage(it) }, @@ -33,28 +33,32 @@ open class GossipRouterBuilder( var subscriptionTopicSubscriptionFilter: TopicSubscriptionFilter = TopicSubscriptionFilter.AllowAllTopicSubscriptionFilter(), var scoreFactory: GossipScoreFactory = - { scoreParams1, scheduledAsyncRxecutor, currentTimeSuppluer1, eventsSubscriber -> - val gossipScore = DefaultGossipScore(scoreParams1, scheduledAsyncRxecutor, currentTimeSuppluer1) + { scoreParams1, scheduledAsyncRxecutor, currentTimeSupplier1, eventsSubscriber -> + val gossipScore = DefaultGossipScore(scoreParams1, scheduledAsyncRxecutor, currentTimeSupplier1) eventsSubscriber(gossipScore) gossipScore }, val gossipRouterEventListeners: MutableList = mutableListOf(), - - var gossipExtensionsConfig: GossipExtensionsConfig = GossipExtensionsConfig() + val enabledGossipExtensions: List = mutableListOf(), ) { - var seenCache: SeenCache> by lazyVar { TTLSeenCache(SimpleSeenCache(), params.seenTTL, currentTimeSuppluer) } + var seenCache: SeenCache> by lazyVar { TTLSeenCache(SimpleSeenCache(), params.seenTTL, currentTimeSupplier) } var mCache: MCache by lazyVar { MCache(params.gossipSize, params.gossipHistoryLength) } private var disposed = false + fun enabledGossipExtensions(vararg gossipExtensions: GossipExtension): GossipRouterBuilder { + (enabledGossipExtensions as MutableList).addAll(gossipExtensions) + return this + } + protected open fun createGossipRouter(): GossipRouter { - val gossipScore = scoreFactory(scoreParams, scheduledAsyncExecutor, currentTimeSuppluer, { gossipRouterEventListeners += it }) + val gossipScore = scoreFactory(scoreParams, scheduledAsyncExecutor, currentTimeSupplier, { gossipRouterEventListeners += it }) val router = GossipRouter( params = params, scoreParams = scoreParams, - currentTimeSupplier = currentTimeSuppluer, + currentTimeSupplier = currentTimeSupplier, random = random, name = name, mCache = mCache, @@ -65,7 +69,7 @@ open class GossipRouterBuilder( messageFactory = messageFactory, seenMessages = seenCache, messageValidator = messageValidator, - gossipExtensionsConfig = gossipExtensionsConfig + gossipExtensionsConfig = buildGossipExtensionsConfig(), ) router.eventBroadcaster.listeners += gossipRouterEventListeners @@ -77,4 +81,11 @@ open class GossipRouterBuilder( disposed = true return createGossipRouter() } + + private fun buildGossipExtensionsConfig(): GossipExtensionsConfig { + return GossipExtensionsConfig( + partialMessagesEnabled = enabledGossipExtensions.contains(GossipExtension.PARTIAL_MESSAGES), + testExtensionEnabled = enabledGossipExtensions.contains(GossipExtension.TEST_EXTENSION) + ) + } } diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt index a17277832..224b5a7ae 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt @@ -16,14 +16,30 @@ class GossipRouterBuilderTest { @Test fun `localExtensionSupport reflects config in built router`() { - val config = GossipExtensionsConfig( - testExtensionEnabled = true, - partialMessagesEnabled = false - ) - val router = GossipRouterBuilder(gossipExtensionsConfig = config).build() + val router = GossipRouterBuilder() + // Enabling only test extensions + .enabledGossipExtensions( + GossipExtension.TEST_EXTENSION + ) + .build() val localSupport = router.gossipExtensionsState.localExtensionSupport assertThat(localSupport.testExtension).isTrue() assertThat(localSupport.partialMessages).isFalse() } + + @Test + fun `localExtensionSupport with all extensions enabled`() { + val router = GossipRouterBuilder() + // Enabling all extensions + .enabledGossipExtensions( + GossipExtension.TEST_EXTENSION, + GossipExtension.PARTIAL_MESSAGES, + ) + .build() + + val localSupport = router.gossipExtensionsState.localExtensionSupport + assertThat(localSupport.testExtension).isTrue() + assertThat(localSupport.partialMessages).isTrue() + } } diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt index f25d7be4b..1917310e9 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt @@ -63,9 +63,8 @@ abstract class GossipTestsBase { val scoreParams: GossipScoreParams = GossipScoreParams(), val mockRouterFactory: DeterministicFuzzRouterFactory = createMockFuzzRouterFactory(), val protocol: PubsubProtocol = PubsubProtocol.Gossip_V_1_1, - val gossipExtensionsConfig: GossipExtensionsConfig = GossipExtensionsConfig( - testExtensionEnabled = true - ) + val enabledGossipExtensions: List = listOf(GossipExtension.TEST_EXTENSION) + ) { val fuzz = DeterministicFuzz() val gossipRouterBuilderFactory = { @@ -73,7 +72,7 @@ abstract class GossipTestsBase { protocol = protocol, params = coreParams, scoreParams = scoreParams, - gossipExtensionsConfig = gossipExtensionsConfig + enabledGossipExtensions = enabledGossipExtensions ) } val router1 = fuzz.createTestRouter(createGossipFuzzRouterFactory(gossipRouterBuilderFactory)) diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt index 64e4f5fc9..c39f4d51b 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt @@ -1,7 +1,9 @@ package io.libp2p.pubsub.gossip.extensions import io.libp2p.pubsub.PubsubProtocol -import io.libp2p.pubsub.gossip.GossipExtensionsConfig +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipPeerScoreParams +import io.libp2p.pubsub.gossip.GossipScoreParams import io.libp2p.pubsub.gossip.GossipTestsBase import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -117,9 +119,7 @@ class GossipExtensionsMessageHandlingTest : GossipTestsBase() { fun `local peer ignores test extension messages when they are disabled in config`() { val test = TwoRoutersTest( protocol = PubsubProtocol.Gossip_V_1_3, - gossipExtensionsConfig = GossipExtensionsConfig( - testExtensionEnabled = false - ) + enabledGossipExtensions = listOf() ) test.mockRouter.sendToSingle(rpcMsgWithCtrlExtensionsAndTestExtension) @@ -135,9 +135,9 @@ class GossipExtensionsMessageHandlingTest : GossipTestsBase() { fun `control extension message contains all supported extensions flags`() { val test = TwoRoutersTest( protocol = PubsubProtocol.Gossip_V_1_3, - gossipExtensionsConfig = GossipExtensionsConfig( - testExtensionEnabled = true, - partialMessagesEnabled = true + enabledGossipExtensions = listOf( + GossipExtension.TEST_EXTENSION, + GossipExtension.PARTIAL_MESSAGES ) ) @@ -193,6 +193,33 @@ class GossipExtensionsMessageHandlingTest : GossipTestsBase() { assertThat(test.gossipRouter.gossipExtensionsState.hasSentControlExtensionsTo(test.router2.peerId)).isFalse() } + @Test + fun `peer sending multiple control extension messages are downscored`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + // Creating GossipScoreParams with behaviourPenaltyWeight (peer bad behavior affecting + // score). Here we are not interested if the weight is "correct". What we want to see if + // that a peer is penalized for sending more than one ControlExtensions message. + scoreParams = GossipScoreParams( + peerScoreParams = GossipPeerScoreParams( + behaviourPenaltyWeight = -1.0 + ) + ) + ) + + val offendingPeer = test.gossipRouter.peers[0].peerId + val initialScore = test.gossipRouter.score.score(offendingPeer) + + // first ControlExtensions message, no downscoring + test.mockRouter.sendToSingle(rpcMessageWithControlExtensions) + assertThat(test.gossipRouter.score.score(offendingPeer)).isEqualTo(initialScore) + + // second ControlExtensions message, peer downscored + test.mockRouter.sendToSingle(rpcMessageWithControlExtensions) + assertThat(test.gossipRouter.score.score(offendingPeer)).isLessThan(initialScore) + } + companion object { @JvmStatic fun protocolVersionsWithExtensionSupport() = listOf( diff --git a/libp2p/src/testFixtures/kotlin/io/libp2p/pubsub/DeterministicFuzz.kt b/libp2p/src/testFixtures/kotlin/io/libp2p/pubsub/DeterministicFuzz.kt index 646ee5c5c..e5050b20e 100644 --- a/libp2p/src/testFixtures/kotlin/io/libp2p/pubsub/DeterministicFuzz.kt +++ b/libp2p/src/testFixtures/kotlin/io/libp2p/pubsub/DeterministicFuzz.kt @@ -47,7 +47,7 @@ class DeterministicFuzz { { executor, curTime, random -> routerBuilderFactory().also { it.scheduledAsyncExecutor = executor - it.currentTimeSuppluer = curTime + it.currentTimeSupplier = curTime it.random = random }.build() } diff --git a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/GossipSimNetwork.kt b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/GossipSimNetwork.kt index e2aa4f0a9..3e874e1b3 100644 --- a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/GossipSimNetwork.kt +++ b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/GossipSimNetwork.kt @@ -45,7 +45,7 @@ class GossipSimNetwork( protected fun createSimPeer(number: Int): GossipSimPeer { val router = routerFactory(number).also { - it.currentTimeSuppluer = { timeController.time } + it.currentTimeSupplier = { timeController.time } it.serializeMessagesToBytes = false } diff --git a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/GossipSimPeer.kt b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/GossipSimPeer.kt index 08eadfaaa..018a4b632 100644 --- a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/GossipSimPeer.kt +++ b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/GossipSimPeer.kt @@ -23,7 +23,7 @@ class GossipSimPeer( routerBuilder.also { it.name = name it.scheduledAsyncExecutor = simExecutor - it.currentTimeSuppluer = { currentTime() } + it.currentTimeSupplier = { currentTime() } it.random = random }.build() } diff --git a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouterBuilder.kt b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouterBuilder.kt index f1ee3f82c..7096b2fdc 100644 --- a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouterBuilder.kt +++ b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouterBuilder.kt @@ -10,12 +10,12 @@ class SimGossipRouterBuilder : GossipRouterBuilder() { override fun createGossipRouter(): GossipRouter { val gossipScore = - scoreFactory(scoreParams, scheduledAsyncExecutor, currentTimeSuppluer) { gossipRouterEventListeners += it } + scoreFactory(scoreParams, scheduledAsyncExecutor, currentTimeSupplier) { gossipRouterEventListeners += it } val router = SimGossipRouter( params = params, scoreParams = scoreParams, - currentTimeSupplier = currentTimeSuppluer, + currentTimeSupplier = currentTimeSupplier, random = random, name = name, mCache = mCache, From 9445f0d331149374cd114aebaf98e0d91f5532da Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Tue, 21 Apr 2026 08:55:06 +1200 Subject: [PATCH 22/30] Fix ConnectionClosedException propagating to Netty event loop from MuxChannel.doWrite (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Netty 4.2.x the original code's throw+catch pattern caused the exception to escape doWrite() uncaught, surfacing as spurious "PLEASE FIX OR REPORT" noise in Teku logs. The throw was caught locally, but buf.remove(cause) — called from the catch block without its own guard — can propagate exceptions back through promise-listener callbacks in certain Netty versions. Fix: move the localDisconnected check outside the try/catch and wrap the buf.remove(cause) call defensively so nothing can escape doWrite(). Pending write promises are still properly failed with ConnectionClosedException. --- .../libp2p/etc/util/netty/mux/MuxChannel.kt | 13 +++++++++--- .../io/libp2p/mux/MuxHandlerAbstractTest.kt | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/libp2p/src/main/kotlin/io/libp2p/etc/util/netty/mux/MuxChannel.kt b/libp2p/src/main/kotlin/io/libp2p/etc/util/netty/mux/MuxChannel.kt index 855046c5a..2c4059c83 100644 --- a/libp2p/src/main/kotlin/io/libp2p/etc/util/netty/mux/MuxChannel.kt +++ b/libp2p/src/main/kotlin/io/libp2p/etc/util/netty/mux/MuxChannel.kt @@ -32,13 +32,20 @@ class MuxChannel( initializer(this) } + @Suppress("SwallowedException") override fun doWrite(buf: ChannelOutboundBuffer) { while (true) { val msg = buf.current() ?: break + if (localDisconnected) { + // Must not throw from doWrite — exceptions escape uncaught to the Netty event loop. + // Wrap buf.remove() defensively: in some Netty versions promise listeners triggered + // by buf.remove() can propagate back through it. + try { + buf.remove(ConnectionClosedException("The stream was closed for writing locally: $id")) + } catch (e: Throwable) { } + continue + } try { - if (localDisconnected) { - throw ConnectionClosedException("The stream was closed for writing locally: $id") - } // the msg is released by both onChildWrite and buf.remove() so we need to retain // however it is still to be confirmed that no buf leaks happen here TODO ReferenceCountUtil.retain(msg) diff --git a/libp2p/src/test/kotlin/io/libp2p/mux/MuxHandlerAbstractTest.kt b/libp2p/src/test/kotlin/io/libp2p/mux/MuxHandlerAbstractTest.kt index bb0f21313..2a74d694e 100644 --- a/libp2p/src/test/kotlin/io/libp2p/mux/MuxHandlerAbstractTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/mux/MuxHandlerAbstractTest.kt @@ -6,6 +6,7 @@ import io.libp2p.core.StreamHandler import io.libp2p.etc.types.fromHex import io.libp2p.etc.types.getX import io.libp2p.etc.types.toHex +import io.libp2p.etc.util.netty.mux.MuxChannel import io.libp2p.etc.util.netty.mux.RemoteWriteClosed import io.libp2p.etc.util.netty.nettyInitializer import io.libp2p.mux.MuxHandlerAbstractTest.AbstractTestMuxFrame.Flag.* @@ -442,6 +443,26 @@ abstract class MuxHandlerAbstractTest { } } + @Test + fun `write with localDisconnected should fail promise without throwing from doWrite`() { + val handler = openStreamLocal() + readFrameOrThrow() + + // Simulate the state between localDisconnected=true and deactivate() in doDisconnect(), + // which is when a queued WriteTask can reach doWrite with localDisconnected=true while + // the channel is still active (flush0 would take the "not-yet-connected" path otherwise). + @Suppress("UNCHECKED_CAST") + (handler.ctx.channel() as MuxChannel).localDisconnected = true + + val writeFuture = handler.ctx.writeAndFlush(allocateMessage("42")) + ech.runPendingTasks() + + assertTrue(writeFuture.isDone) + assertThrows(ConnectionClosedException::class.java) { + writeFuture.sync() + } + } + @Test fun `should throw when writing to reset stream`() { val handler = openStreamLocal() From a88d1ed9f060e7ca1bd1cfea41fda703f61b8022 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Thu, 23 Apr 2026 20:33:16 +0200 Subject: [PATCH 23/30] Update dependencies (#459) * Update dependencies Plugins: - com.diffplug.spotless 6.25.0 -> 7.2.1 - io.spring.dependency-management 1.1.6 -> 1.1.7 - me.champeau.jmh 0.7.2 -> 0.7.3 - com.google.protobuf (gradle) 0.9.4 -> 0.9.5 - Gradle wrapper 8.10.2 -> 8.14.3 Libraries: - log4j 2.24.1 -> 2.25.4 - junit-jupiter 5.11.3 -> 5.13.4 - junit-platform 1.13.4 (new explicit dependency) - assertj-core 3.26.3 -> 3.27.4 - protobuf-java 3.25.5 -> 4.32.1 - netty 4.2.5.Final -> 4.2.12.Final - netty-tcnative-boringssl-static 2.0.73.Final -> 2.0.76.Final - bouncycastle 1.78.1 -> 1.80 - guava 33.3.1-jre -> 33.4.8-jre - slf4j-api 2.0.9 -> 2.0.17 - ktlint version pinned to 1.1.1 Other: - Remove jmh from explicit dependencies (managed by me.champeau.jmh plugin) - Use ParameterizedInvocationConstants.INDEX_PLACEHOLDER (junit 5.13+) - Remove dead commented-out debug code in GossipScoreBenchmark * Fix detekt failing on JVM 21 detekt 1.22.0 only accepts jvmTarget up to 18, so it fails when Gradle runs on Java 21. Explicitly setting jvmTarget = "11" on the Detekt tasks matches the project's configured Kotlin target and unblocks the check. detekt 1.23.x would fix this properly but requires Kotlin 1.9+, which is a separate upgrade. * Revert slf4j and bouncycastle upgrades for Android compatibility AGP 7.4.2's D8 dexer cannot process the newer bytecode format used by slf4j-api 2.0.17 and bouncycastle 1.80, causing the android-chatter example module to fail to build. Reverting to versions compatible with the old D8 toolchain. Unblocked once AGP is upgraded (tracked in #458 as part of Kotlin 2.x work). * Revert guava upgrade for Android compatibility guava 33.4.8-jre transitively brings in error_prone_annotations 2.36.0, which AGP 7.4.2's D8 dexer cannot process. Reverting to 33.3.1-jre which pulls in 2.28.0, the previously working version. Unblocked once AGP is upgraded (tracked in #458). * Revert protobuf upgrade for Android compatibility protobuf-java 4.32.1 generates bytecode that AGP 7.4.2's D8 dexer cannot process (IdentifyOuterClass and similar generated classes fail to dex). Reverting to 3.25.5 (the current develop version) restores Android compatibility. protobuf 4.x can be revisited once AGP is upgraded (tracked in #458). * Change Netty version in versions.gradle Updated Netty version from 4.2.12.Final to 4.2.10.Final. --- build.gradle.kts | 15 +++++-- gradle/wrapper/gradle-wrapper.jar | Bin 61608 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 5 ++- gradlew | 37 +++++++++++------- gradlew.bat | 26 ++++++------ libp2p/build.gradle.kts | 4 +- .../pubsub/gossip/GossipScoreBenchmark.java | 8 ---- .../pubsub/gossip/GossipRpcPartsQueueTest.kt | 3 +- versions.gradle | 20 +++++----- 9 files changed, 63 insertions(+), 55 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7d11e4769..be4c7b221 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,9 +18,9 @@ plugins { id("java") id("maven-publish") id("org.jetbrains.dokka").version("1.9.20") - id("com.diffplug.spotless").version("6.25.0") + id("com.diffplug.spotless").version("7.2.1") id("java-test-fixtures") - id("io.spring.dependency-management").version("1.1.6") + id("io.spring.dependency-management").version("1.1.7") id("org.jetbrains.kotlin.android") version kotlinVersion apply false id("com.android.application") version "7.4.2" apply false @@ -63,6 +63,8 @@ configure( testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter-params") + testImplementation("org.junit.platform:junit-platform-launcher") + testRuntimeOnly("org.junit.platform:junit-platform-engine") testImplementation("io.mockk:mockk") testImplementation("org.assertj:assertj-core") testRuntimeOnly("org.apache.logging.log4j:log4j-slf4j2-impl") @@ -84,6 +86,9 @@ configure( tasks.withType { duplicatesStrategy = DuplicatesStrategy.INCLUDE } + tasks.withType().configureEach { + jvmTarget = "11" + } // Parallel build execution tasks.test { @@ -108,8 +113,10 @@ configure( } configure { + // https://github.com/pinterest/ktlint/releases + val ktlintVersion = "1.1.1" kotlin { - ktlint().editorConfigOverride( + ktlint(ktlintVersion).editorConfigOverride( mapOf( "ktlint_standard_no-wildcard-imports" to "disabled", "ktlint_standard_enum-entry-name-case" to "disabled", @@ -140,7 +147,7 @@ configure( jdkVersion.set(11) reportUndocumented.set(false) externalDocumentationLink { - url.set(URI.create("https://netty.io/4.1/api/").toURL()) + url.set(URI.create("https://netty.io/4.2/api/").toURL()) } } } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7710deaf9f98673a68957ea02138b60d0a..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch literal 43764 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6a93cb7a1..78cb6e16a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 79a61d421..23d15a936 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -83,10 +85,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -133,10 +133,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. @@ -144,7 +147,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +155,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -197,16 +200,20 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 93e3f59f1..db3a6ac20 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,22 +59,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/libp2p/build.gradle.kts b/libp2p/build.gradle.kts index 26fa61f56..d9037e61c 100644 --- a/libp2p/build.gradle.kts +++ b/libp2p/build.gradle.kts @@ -1,6 +1,6 @@ plugins { id("com.google.protobuf").version("0.9.4") - id("me.champeau.jmh").version("0.7.2") + id("me.champeau.jmh").version("0.7.3") } // https://docs.gradle.org/current/userguide/java_testing.html#ex-disable-publishing-of-test-fixtures-variants @@ -46,8 +46,6 @@ dependencies { testFixturesImplementation("org.junit.jupiter:junit-jupiter-api") jmhImplementation(project(":tools:schedulers")) - jmhImplementation("org.openjdk.jmh:jmh-core") - jmhAnnotationProcessor("org.openjdk.jmh:jmh-generator-annprocess") } protobuf { diff --git a/libp2p/src/jmh/java/io/libp2p/pubsub/gossip/GossipScoreBenchmark.java b/libp2p/src/jmh/java/io/libp2p/pubsub/gossip/GossipScoreBenchmark.java index ce87b32d0..1128a2512 100644 --- a/libp2p/src/jmh/java/io/libp2p/pubsub/gossip/GossipScoreBenchmark.java +++ b/libp2p/src/jmh/java/io/libp2p/pubsub/gossip/GossipScoreBenchmark.java @@ -107,12 +107,4 @@ public void scoresDelay10000(Blackhole bh) { bh.consume(s); } } - - /** Uncomment for debugging */ - // public static void main(String[] args) { - // GossipScoreBenchmark benchmark = new GossipScoreBenchmark(); - // Blackhole blackhole = new Blackhole("Today's password is swordfish. I understand - // instantiating Blackholes directly is dangerous."); - // benchmark.scoresDelay0(blackhole); - // } } diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueueTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueueTest.kt index e978877d2..087b1d0c4 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueueTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueueTest.kt @@ -8,6 +8,7 @@ import io.libp2p.pubsub.gossip.builders.GossipParamsBuilder import io.libp2p.pubsub.gossip.builders.GossipRouterBuilder import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedInvocationConstants import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource @@ -186,7 +187,7 @@ class GossipRpcPartsQueueTest { fun mergeParams(): Stream = testCases.stream() } - @ParameterizedTest(name = "[${ParameterizedTest.INDEX_PLACEHOLDER}] {0}") + @ParameterizedTest(name = "[${ParameterizedInvocationConstants.INDEX_PLACEHOLDER}] {0}") @MethodSource("mergeParams") fun `mergeMessageParts() test various combinations`( gossipParams: GossipParams, diff --git a/versions.gradle b/versions.gradle index 50736ab7b..c6e59e148 100644 --- a/versions.gradle +++ b/versions.gradle @@ -9,29 +9,29 @@ dependencyManagement { dependency "com.google.guava:guava:33.3.1-jre" dependency "org.slf4j:slf4j-api:2.0.9" - dependencySet(group: 'org.apache.logging.log4j', version: '2.24.1') { + dependencySet(group: 'org.apache.logging.log4j', version: '2.25.4') { entry 'log4j-core' entry 'log4j-slf4j2-impl' } - dependencySet(group: 'org.junit.jupiter', version: '5.11.3') { + dependencySet(group: 'org.junit.jupiter', version: '5.13.4') { entry 'junit-jupiter-api' entry 'junit-jupiter-engine' entry 'junit-jupiter-params' } - dependency "io.mockk:mockk:1.13.3" - dependency "org.assertj:assertj-core:3.26.3" + dependencySet(group: 'org.junit.platform', version: '1.13.4') { + entry 'junit-platform-launcher' + entry 'junit-platform-engine' - dependencySet(group: "org.openjdk.jmh", version: "1.37") { - entry 'jmh-core' - entry 'jmh-generator-annprocess' } + dependency "io.mockk:mockk:1.13.3" + dependency "org.assertj:assertj-core:3.27.4" dependencySet(group: "com.google.protobuf", version: "3.25.5") { entry 'protobuf-java' entry 'protoc' } - dependencySet(group: "io.netty", version: "4.2.5.Final") { + dependencySet(group: "io.netty", version: "4.2.10.Final") { entry 'netty-common' entry 'netty-handler' entry 'netty-transport' @@ -41,7 +41,7 @@ dependencyManagement { entry 'netty-codec-native-quic' entry 'netty-transport-classes-epoll' } - dependency "io.netty:netty-tcnative-boringssl-static:2.0.73.Final" + dependency "io.netty:netty-tcnative-boringssl-static:2.0.76.Final" dependency "com.github.multiformats:java-multibase:v1.1.1" dependency "tech.pegasys:noise-java:22.1.0" dependencySet(group: "org.bouncycastle", version: "1.78.1") { @@ -50,4 +50,4 @@ dependencyManagement { entry 'bctls-jdk18on' } } -} \ No newline at end of file +} From b7d836178e9fac4c9ab215496db56bc9f8d4c8d0 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Sun, 26 Apr 2026 12:14:16 +0200 Subject: [PATCH 24/30] Add partial-messages extension design document (#457) Captures the MVP scope, jvm-libp2p/client responsibility boundary, client-facing API, routing semantics, per-group lifecycle and DoS caps, and the implementation plan for the gossipsub partial-messages extension. Lands ahead of implementation so sub-issues of #435 can reference a stable design anchor. --- docs/partial-messages.md | 450 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 docs/partial-messages.md diff --git a/docs/partial-messages.md b/docs/partial-messages.md new file mode 100644 index 000000000..4c715a7b0 --- /dev/null +++ b/docs/partial-messages.md @@ -0,0 +1,450 @@ +# Gossipsub Partial Messages — Design Document + +Status: **Draft / MVP design** +Tracking issue: [libp2p/jvm-libp2p#435](https://github.com/libp2p/jvm-libp2p/issues/435) +Last updated: see `git log -- docs/partial-messages.md` + +This document is the source of truth for the jvm-libp2p implementation of the +gossipsub partial-messages extension. It captures the scope, the jvm-libp2p ↔ +client responsibility boundary, the public API, routing semantics, and the +implementation plan. It is a **living document** — append to the decision log +(§9) when we revise anything. + +--- + +## 1. Scope and non-goals + +### In scope (MVP) + +- Full wire-level support for the `PartialMessagesExtension` RPC: + - Per-topic negotiation via `SubOpts.requestsPartial` / `SubOpts.supportsSendingPartial`. + - Inbound and outbound handling of `RPC.partial`. + - Both metadata-only and payload-only variants in both directions. +- A Kotlin API that lets a client (Teku) plug in its own per-peer state, + metadata encoding, group-ID generation, part-level validation, and publish + decisions. +- Integration with the existing gossipsub routing rules: + - Suppress full-message send to peers that requested partial on that topic. + - Suppress IDONTWANT to peers we request partial from. + - Replace IHAVE with an `onEmitGossip` callback for partial-capable peers + in the lazy-push loop. +- Per-group lifecycle (TTL in heartbeats, DoS caps on peer-initiated groups). +- A side-channel `peerFeedback` API so the client can drive peer scoring + explicitly instead of via callback return values. + +### Non-goals (deferred, but documented for future) + +- `interop-test-client` partial-messages support. Deferred; see §7 for notes. +- Partial-specific peer-scoring rules beyond what the Extensions handshake + already enforces. Spec is silent; match go-libp2p (no scoring) for MVP. +- Topic-level "partial-only" mode. Spec explicitly defers this to a future + extension. +- Reassembling a full `Message` and re-entering the normal gossip flow. MVP + delivers parts upward to the application only; the application is free to + never republish a reconstructed full message (matches Ethereum PeerDAS). +- New wire messages. Spec and go-libp2p use the single + `PartialMessagesExtension` for both lazy-push and payload delivery — no + `partialIHAVE` / `partialIWANT`. + +--- + +## 2. Reference pins + +The partial-messages spec is **Lifecycle 1A (Working Draft)** and may change. +When revising this document, update these pins. + +| Source | Pin | Location | +|---|---|---| +| libp2p/specs | merge commit `6b6203ee` (PR #685, merged 2026-02-26) | `pubsub/gossipsub/partial-messages.md` | +| libp2p/go-libp2p-pubsub | `master` at time of MVP (note in decision log when pinned) | `extensions.go`, `partialmessages/partialmsgs.go`, `gossipsub.go`, `pubsub.go` | +| libp2p/test-plans gossipsub-interop | `master` | `gossipsub-interop/go-libp2p/experiment.go`, `main.go` | +| OffchainLabs/prysm | branch `prysm/partial-cells-current`, latest seen `e8480a86` (2026-03-31) | `beacon-chain/p2p/partialdatacolumnbroadcaster/`, `consensus-types/blocks/partialdatacolumn.go`, `proto/prysm/v1alpha1/partial_data_columns.proto` | + +### Related in-flight spec work (watch) + +- libp2p/specs#681 — Choke extension. +- libp2p/specs#699 — Topic table. +- libp2p/specs#706 — Gossipsub v1.4. +- libp2p/specs#654 — Message preamble. + +None directly modify partial-messages, but v1.4 and message-preamble overlap +in motivation. + +--- + +## 3. Responsibility boundary (jvm-libp2p ↔ client) + +The one-line model: + +> **jvm-libp2p is a transport + per-peer bookkeeper for opaque partial-message +> RPCs. The client (Teku) owns everything about what those bytes mean, when a +> group is "complete", and who gets what.** + +| Concern | jvm-libp2p | Client (Teku) | +|---|---|---| +| v1.3 Control Extensions handshake | ✅ (done on this branch) | — | +| `SubOpts.requestsPartial` / `supportsSendingPartial` wire handling | ✅ | — | +| Per-peer partial-capability state (node-level and topic-level) | ✅ | — | +| Per-`(topic, groupID)` state container, TTL GC, DoS caps | ✅ | — | +| Routing: suppress full-msg send to partial-requesting peers | ✅ | — | +| Routing: suppress IDONTWANT to peers we request partial from | ✅ | — | +| Routing: replace IHAVE with `onEmitGossip` for partial peers | ✅ | — | +| Wire framing of `PartialMessagesExtension` in/out | ✅ | — | +| Spec MUST: omit `partialMessage` if peer supports-but-didn't-request | ✅ | — | +| `partsMetadata` encoding (bitmap / Bloom / whatever) | ❌ opaque | ✅ | +| `groupID` generation | ❌ opaque | ✅ | +| Merging incoming `partsMetadata` into local per-peer view | ❌ | ✅ | +| Deciding which parts to send to which peer | ❌ | ✅ (`PublishActionsFn`) | +| Reassembling a full message | ❌ never | ✅ | +| Validating individual parts (e.g. KZG) | ❌ | ✅ (inside `onIncomingRpc`) | +| Detecting "group complete" and delivering upward | ❌ | ✅ | +| Per-part peer scoring (spammy parts, etc.) | ❌ MVP | Future, in coordination | + +Rationale for each line is grounded in go-libp2p's and Prysm's current +behaviour — see §9 and the research notes that produced this document. + +--- + +## 4. Public API (jvm-libp2p surface) + +### 4.1 Builder wiring + +```kotlin +GossipRouterBuilder().apply { + enabledGossipExtensions(GossipExtension.PARTIAL_MESSAGES) + partialMessagesHandler = MyTekuPartialMessagesHandler() // new +} +``` + +- The `GossipExtension.PARTIAL_MESSAGES` feature flag stays as the capability + switch (already wired). +- `partialMessagesHandler: PartialMessagesHandler<*>?` is a new optional + field on the builder. Null + flag enabled = build-time error. + +### 4.2 Client-supplied handler + +```kotlin +interface PartialMessagesHandler { + + /** + * Called on every inbound PartialMessagesExtension RPC on the pubsub + * event thread. MUST be fast and non-blocking: dispatch heavy work + * (decoding, validation) to your own executor. + * + * Any of rpc.partialMessage and rpc.partsMetadata may be absent; all + * four combinations are valid. + */ + fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension + ) + + /** + * Called once per group during the gossipsub heartbeat, for gossip + * targets that are partial-capable. The client typically responds by + * calling publishPartial(...) for the same (topic, groupId). + */ + fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map + ) +} +``` + +Notes: +- `PeerState` is fully generic. The library stores it per + `(topic, groupId, peerId)` and never interprets it. +- Both callbacks run on the pubsub event thread. Document prominently. + +### 4.3 Publishing + +```kotlin +fun interface PublishActionsFn { + fun decide( + peerStates: Map, + peerRequestsPartial: (PeerId) -> Boolean + ): Sequence>> +} + +data class PublishAction( + val partialMessage: ByteArray? = null, + val partsMetadata: ByteArray? = null, + val nextPeerState: PeerState? = null, // library applies atomically + val error: Throwable? = null +) + +// Entry point on the Gossip facade +fun Gossip.publishPartial( + topic: Topic, + groupId: ByteArray, + actions: PublishActionsFn<*> +): CompletableFuture +``` + +Key API differences vs. go-libp2p (deliberate): + +1. **No in-place map mutation.** `PublishAction.nextPeerState` is applied + atomically by the library per peer, instead of asking the client to + mutate `Map` inside the iterator. Prysm has fixed race + bugs in the in-place pattern (see commits on `prysm/partial-cells-current`, + Mar 31 2026); Kotlin's single-threaded event loop makes the atomic-return + shape natural. +2. **`Unit`-returning callbacks.** Errors do not drive scoring; see §4.4. + +### 4.4 Peer feedback (scoring side-channel) + +```kotlin +interface PartialMessagesPeerFeedback { + fun reportFeedback(topic: Topic, peer: PeerId, kind: FeedbackKind) +} + +enum class FeedbackKind { USEFUL, INVALID, IGNORED } +``` + +The handler receives a `PartialMessagesPeerFeedback` instance (via +constructor or context object — TBD during implementation) and uses it to +drive peer score adjustments. This mirrors Prysm's `peerFeedback` pattern. +`INVALID` hooks into the existing `notifyRouterMisbehavior` path. + +### 4.5 Topic options + +Subscribing to a topic with partial-message flags: + +```kotlin +gossip.subscribe(topic, handler, + requestsPartial = true, + supportsSendingPartial = true) // implied if requestsPartial = true +``` + +Go-libp2p exposes `RequestPartialMessages()` and `SupportsPartialMessages()` +as separate topic options. In Prysm's real integration, only +`RequestPartialMessages()` is ever used; the "supports-but-doesn't-request" +half is currently unexercised. MVP supports both flags in the API but only +the `requests` path needs end-to-end testing. + +--- + +## 5. Routing rules (inside `GossipRouter`) + +Three modifications to the existing routing, all behind +`partialMessagesEnabled()` and the per-peer handshake state. + +### 5.1 Full-message suppression + +When broadcasting a `Message` for topic `T` to peer `P`: +- If `gossipExtensionsState.peerSupportsPartialMessages(P)` **and** + `partialTopicState.peerRequestsPartial(P, T)` → **do not** send the full + message to `P`. The client is responsible for pushing parts via + `publishPartial(...)`. +- This filter applies in `broadcastInbound` and `broadcastOutbound`, before + messages are queued into `GossipRpcPartsQueue`. +- Spec MUST (§Wire rules): if peer supports sending partial but did *not* + request, we still send the full message, but when we send a + `PartialMessagesExtension` to that peer we MUST omit `partialMessage`. + +### 5.2 IDONTWANT suppression + +When emitting IDONTWANT for a message on topic `T`: +- If, for peer `P`, we `iRequestPartial(T)` **and** + `peerSupportsSendingPartial(P, T)` → skip IDONTWANT to `P`. +- go-libp2p: `gossipsub.go:892-904`. + +### 5.3 IHAVE replacement with `onEmitGossip` + +During gossipsub heartbeat lazy-push: +- Partition the selected IHAVE targets into `fullPeers` and + `partialPeers = { p | iSupportSendingPartial(T) ∧ peerRequestsPartial(p, T) }`. +- Do not enqueue IHAVE for `partialPeers`. +- After the normal loop, for every locally-initiated group under `T`, call + `handler.onEmitGossip(T, groupId, partialPeers, peerStatesForGroup)` once. +- go-libp2p: `gossipsub.go:2018-2074`. + +--- + +## 6. State and lifecycle + +### 6.1 Per-topic-per-peer partial-capability state + +Per-peer flags per topic, updated from every inbound `SubOpts` (where +`subscribe = true`): + +- `requestsPartial: Boolean` +- `supportsSendingPartial: Boolean` + +Spec + go-libp2p coercion: on receive, store +`supportsSendingPartial := requestsPartial || supportsSendingPartial`. + +MUST ignore both flags on `SubOpts` with `subscribe = false`. + +### 6.2 Per-`(topic, groupID)` group state + +``` +GroupState { + ttlInHeartbeats: Int // counts down each heartbeat, GC at 0 + peerInitiated: Boolean // true if first seen from a peer, not us + peerStates: Map // app-opaque +} +``` + +- Stored in a plain `HashMap` — not thread-safe; access is serialised on the + pubsub event loop (per the project-wide invariant; do **not** use + `ConcurrentHashMap`). +- TTL reset whenever `publishPartial(topic, groupId, …)` is called for the + group. +- GC on `ttl == 0` **or** `peerStates` empty. + +### 6.3 DoS caps (match go-libp2p defaults) + +Applies only to **peer-initiated** groups (first touched from an inbound +RPC, not via `publishPartial`). + +| Cap | Default | Where | +|---|---|---| +| `peerInitiatedGroupLimitPerTopic` | 255 | Across all peers, per topic | +| `peerInitiatedGroupLimitPerTopicPerPeer` | 8 | Per (topic, peer) | + +Over-cap: log and drop the RPC. No disconnect. No score penalty (match go; +revise if spec adds guidance). + +### 6.4 Cleanup hooks + +- Peer disconnect → remove all `peerStates[peer]` entries across groups. +- Unsubscribe (we leave a topic) → drop all group state for that topic. +- Heartbeat → decrement TTLs, GC expired groups. + +--- + +## 7. Known gaps vs. full spec + +Explicitly deferred in MVP; listed here so future work can pick them up. + +1. **Validator pipeline for partial RPCs** — bypassed entirely (matches + go-libp2p). Client validates inside `onIncomingRpc`. +2. **Scoring rules for partial misbehaviour** — spec silent, go silent. MVP + only scores via the existing `notifyRouterMisbehavior` path plus the + client's `peerFeedback` calls. +3. **Message-ID of reassembled full messages** — spec silent. MVP does not + reassemble at all; the reconstructed message never re-enters gossip. +4. **Topic-level "partial-only" mode** — spec explicitly defers; no + implementation. +5. **`SupportsPartialMessages()`-only (support without request) path** — + supported by the API, but Prysm doesn't exercise it and we don't have an + end-to-end test for it. Flag if we ship without coverage. +6. **Fanout peers in publish** — MVP does mesh peers (+ fanout fallback if + mesh empty), mirroring go-libp2p's `MeshPeers`. Fanout specifically for + partial is not independently exercised. +7. **`interop-test-client`** — deferred. Future work should: + - Implement `PartialMessagesHandler` with SSZ-like + bitlists for `partsMetadata`. + - Test the 4-combo matrix (payload+meta / meta-only / payload-only / + neither) on both send and receive. + - Test mixed-peer topic: one partial-enabled node, one full-only; verify + full-only path still works end-to-end. + - Test `ControlExtensions` handshake ordering: extension RPCs arriving + before the handshake completes must be ignored. + +--- + +## 8. Implementation plan + +Order chosen so an end-to-end partial round-trip works before any of the +fragile routing rules are touched. Each step is independently testable and +mergeable. + +Mirror this checklist in issue #435. + +- [ ] **Step 1** — Per-topic `SubOpts` flag plumbing. Outbound: flags added + to subscribe announce RPCs. Inbound: parse flags into a + `PartialTopicState` (`Map>`). + Coercion rule applied on receive. Flags ignored on `subscribe=false`. +- [ ] **Step 2** — `PartialMessagesHandler` interface, + `PublishAction` (with `nextPeerState`), + `PublishActionsFn`, `PartialMessagesPeerFeedback`, and + `GroupState` container with TTL + DoS caps. No routing yet. +- [ ] **Step 3** — Inbound `RPC.partial` dispatch: replace the stub at + `GossipRouter.kt:476` with the full flow (validate caps, create/update + group state, call `onIncomingRpc`). +- [ ] **Step 4** — Outbound `publishPartial(...)` on the `Gossip` facade; + route through `GossipRpcPartsQueue` (do **not** bypass — PR #433 got + this wrong). Enforce the "omit `partialMessage` when peer supports but + didn't request" MUST. +- [ ] **Step 5** — End-to-end integration test with a trivial bitmap-based + handler. Exercises Steps 1-4 before any routing changes. +- [ ] **Step 6** — Routing: full-message suppression (§5.1). +- [ ] **Step 7** — Routing: IDONTWANT suppression (§5.2). +- [ ] **Step 8** — Heartbeat tick + TTL GC + cleanup hooks (§6.4). +- [ ] **Step 9** — Routing: IHAVE replacement with `onEmitGossip` (§5.3). +- [ ] **Step 10** — Simulator scenario + mixed-peer interop test (partial + + non-partial nodes on the same topic). + +--- + +## 9. Decision log + +Append entries here when design choices change. Keep most-recent on top. + +### 2026-04-20 — Initial design + +- Scope, boundary, and API agreed per research summarised in this document. +- `PublishAction` returns `nextPeerState` rather than asking the client to + mutate a shared map in place. Motivation: cleaner Kotlin ergonomics, + avoids the category of race that Prysm's + `prysm/partial-cells-current` fixed on 2026-03-31. +- Peer scoring feedback lives on a side-channel + `PartialMessagesPeerFeedback`, not on callback return values. Matches + Prysm's `peerFeedback` pattern. +- MVP does not ship `interop-test-client` support; see §7.7 for the future + checklist. +- DoS caps pinned to go-libp2p defaults (255 / 8). +- Spec pinned to libp2p/specs#685 merge `6b6203ee`. Spec is lifecycle 1A; + revise this document when spec revisions land. + +### Open questions to resolve during implementation + +- Exact wiring of `PartialMessagesPeerFeedback` — constructor arg on the + handler, or a context object passed to each callback? Decide during + Step 2. +- Whether `publishPartial` on the `Gossip` facade takes a single + `(topic, groupId)` or supports batched `Seq<(topic, groupId)>`. Prysm + calls per-topic and iterates; MVP will match. +- Exact return type of `publishPartial` — `CompletableFuture` follows + jvm-libp2p convention; finalise during Step 4. + +--- + +## 10. References + +### Spec + +- [libp2p/specs — Gossipsub Partial Messages spec (PR #685)](https://github.com/libp2p/specs/pull/685) +- [libp2p/specs — partial-messages.md @ 6b6203ee](https://github.com/libp2p/specs/blob/6b6203ee16ef2e01e6b86fc8f6c3fae0d1c6490e/pubsub/gossipsub/partial-messages.md) + +### Related in-flight spec work + +- [libp2p/specs#681 — Choke extension](https://github.com/libp2p/specs/pull/681) +- [libp2p/specs#699 — Topic table](https://github.com/libp2p/specs/pull/699) +- [libp2p/specs#706 — Gossipsub v1.4](https://github.com/libp2p/specs/pull/706) +- [libp2p/specs#654 — Message preamble](https://github.com/libp2p/specs/pull/654) + +### Implementations + +- [go-libp2p-pubsub — extensions.go](https://github.com/libp2p/go-libp2p-pubsub/blob/master/extensions.go) +- [go-libp2p-pubsub — partialmessages/partialmsgs.go](https://github.com/libp2p/go-libp2p-pubsub/blob/master/partialmessages/partialmsgs.go) +- [go-libp2p-pubsub — gossipsub.go](https://github.com/libp2p/go-libp2p-pubsub/blob/master/gossipsub.go) +- [go-libp2p-pubsub — pubsub.go](https://github.com/libp2p/go-libp2p-pubsub/blob/master/pubsub.go) +- [OffchainLabs/prysm — branch `prysm/partial-cells-current`](https://github.com/OffchainLabs/prysm/tree/prysm/partial-cells-current) + - [`beacon-chain/p2p/partialdatacolumnbroadcaster/`](https://github.com/OffchainLabs/prysm/tree/prysm/partial-cells-current/beacon-chain/p2p/partialdatacolumnbroadcaster) + - [`consensus-types/blocks/partialdatacolumn.go`](https://github.com/OffchainLabs/prysm/blob/prysm/partial-cells-current/consensus-types/blocks/partialdatacolumn.go) + - [`proto/prysm/v1alpha1/partial_data_columns.proto`](https://github.com/OffchainLabs/prysm/blob/prysm/partial-cells-current/proto/prysm/v1alpha1/partial_data_columns.proto) + +### Interop testing + +- [libp2p/test-plans — gossipsub-interop experiment.go](https://github.com/libp2p/test-plans/blob/master/gossipsub-interop/go-libp2p/experiment.go) +- [libp2p/test-plans — gossipsub-interop main.go](https://github.com/libp2p/test-plans/blob/master/gossipsub-interop/go-libp2p/main.go) + +### Tracking + +- [libp2p/jvm-libp2p#435 — Partial messages tracking issue](https://github.com/libp2p/jvm-libp2p/issues/435) From 1af87c74be62f12bdf9b27cffa4593cb752ccd04 Mon Sep 17 00:00:00 2001 From: jepe <15258808+murdak@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:17:17 +0800 Subject: [PATCH 25/30] Fixed mainClassname. (#453) --- examples/pinger/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/pinger/build.gradle b/examples/pinger/build.gradle index b4ce18580..f6dd794d5 100644 --- a/examples/pinger/build.gradle +++ b/examples/pinger/build.gradle @@ -9,5 +9,5 @@ dependencies { } application { - mainClassName = 'io.libp2p.example.Pinger' + mainClassName = 'io.libp2p.example.ping.Pinger' } \ No newline at end of file From de6d0ec9634ef427016ce1ef665627f596ff6a9c Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Mon, 27 Apr 2026 15:01:05 +0200 Subject: [PATCH 26/30] Update .gitignore to include .worktrees (#462) --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e2ca52ac8..73272b159 100644 --- a/.gitignore +++ b/.gitignore @@ -196,4 +196,5 @@ package-lock.json # Claude CLAUDE.local.md -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json +.worktrees/ From 772b2b0b895eb36b657cc4d1210a29d875bee03f Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Mon, 27 Apr 2026 15:38:10 +0200 Subject: [PATCH 27/30] Skip CI for documentation-only changes (#464) Add paths-ignore filters to build and publish workflows so pushes and PRs that only touch markdown files, docs/, license files, .gitignore, or issue templates do not trigger a full build. --- .github/workflows/build.yml | 20 +++++++++++++++++++- .github/workflows/publish.yml | 8 ++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 31f6920e5..1bf1e8196 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,23 @@ name: build -on: [push, pull_request] +on: + push: + paths-ignore: + - '**/*.md' + - 'docs/**' + - '.gitignore' + - 'LICENSE-APACHE' + - 'LICENSE-MIT' + - 'funding.json' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + paths-ignore: + - '**/*.md' + - 'docs/**' + - '.gitignore' + - 'LICENSE-APACHE' + - 'LICENSE-MIT' + - 'funding.json' + - '.github/ISSUE_TEMPLATE/**' jobs: gradle: strategy: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0eeea244e..bc94539fa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,14 @@ on: push: branches: - "develop" + paths-ignore: + - '**/*.md' + - 'docs/**' + - '.gitignore' + - 'LICENSE-APACHE' + - 'LICENSE-MIT' + - 'funding.json' + - '.github/ISSUE_TEMPLATE/**' jobs: publish: runs-on: ubuntu-latest From ddbf62a36eee04e1db5299861b54ba1fd86d8a99 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Wed, 29 Apr 2026 09:58:55 +0200 Subject: [PATCH 28/30] Filter backoff peers in subscribe() and make v1.3 honour backoff/IDONTWANT (#472) GossipRouter.subscribe() seeded the new mesh from fanout/other peers filtered by score and direct-peer status only, omitting the !isBackOff check that the heartbeat-driven mesh maintenance paths apply. When a local subscribe followed a recent PRUNE (e.g. attestation-subnet rotation right after the remote pruned us), the router immediately GRAFTed the still-backed-off peer, accumulating P7 behaviour penalties on the remote scorer until the peer score crossed the disconnect threshold. Mirror the JOIN path in go-libp2p-pubsub by filtering backed-off peers out of both seed lists. Also make supportsBackoffAndPX() and supportsIDontWant() cumulative across GossipSub versions: v1.3 is a strict superset of v1.2 and v1.1, so a router negotiating /meshsub/1.3.0 must continue to honour PRUNE backoff, peer exchange, and IDONTWANT. Previously the exact-match checks silently disabled all three for v1.3 routers. --- .../kotlin/io/libp2p/pubsub/PubsubProtocol.kt | 4 +- .../io/libp2p/pubsub/gossip/GossipRouter.kt | 9 +++- .../io/libp2p/pubsub/PubsubProtocolTest.kt | 34 +++++++++++++ .../libp2p/pubsub/gossip/GossipV1_1Tests.kt | 51 +++++++++++++++++++ .../libp2p/pubsub/gossip/GossipV1_3Tests.kt | 44 ++++++++++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubProtocolTest.kt diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt index 2d421c58c..1b6186b02 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt @@ -19,14 +19,14 @@ enum class PubsubProtocol(val announceStr: ProtocolId) { * https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.1.md#prune-backoff-and-peer-exchange */ fun supportsBackoffAndPX(): Boolean { - return this == Gossip_V_1_1 || this == Gossip_V_1_2 + return this == Gossip_V_1_1 || this == Gossip_V_1_2 || this == Gossip_V_1_3 } /** * https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.2.md#idontwant-message */ fun supportsIDontWant(): Boolean { - return this == Gossip_V_1_2 + return this == Gossip_V_1_2 || this == Gossip_V_1_3 } /** diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt index bdfe69055..33271e190 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt @@ -589,11 +589,16 @@ open class GossipRouter( override fun subscribe(topic: Topic) { super.subscribe(topic) + // Peers that are still within their PRUNE backoff window must be excluded when + // seeding the mesh on (re-)subscribe; grafting them during backoff is a P7 + // behaviour-penalty violation in go-libp2p-pubsub scorers and matches the JOIN + // path of the reference implementation. Heartbeat-driven mesh maintenance has + // always filtered by isBackOff; this path historically did not. val fanoutPeers = (fanout[topic] ?: mutableSetOf()) - .filter { score.score(it.peerId) >= 0 && !isDirect(it) } + .filter { score.score(it.peerId) >= 0 && !isDirect(it) && !isBackOff(it, topic) } val meshPeers = mesh.getOrPut(topic) { mutableSetOf() } val otherPeers = (getTopicPeers(topic) - meshPeers - fanoutPeers) - .filter { score.score(it.peerId) >= 0 && !isDirect(it) } + .filter { score.score(it.peerId) >= 0 && !isDirect(it) && !isBackOff(it, topic) } if (meshPeers.size < params.D) { val addFromFanout = fanoutPeers.shuffled(random) diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubProtocolTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubProtocolTest.kt new file mode 100644 index 000000000..f121d17ec --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubProtocolTest.kt @@ -0,0 +1,34 @@ +package io.libp2p.pubsub + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PubsubProtocolTest { + + @Test + fun `supportsBackoffAndPX is true for all GossipSub versions from v1_1 onwards`() { + assertThat(PubsubProtocol.Gossip_V_1_0.supportsBackoffAndPX()).isFalse() + assertThat(PubsubProtocol.Gossip_V_1_1.supportsBackoffAndPX()).isTrue() + assertThat(PubsubProtocol.Gossip_V_1_2.supportsBackoffAndPX()).isTrue() + assertThat(PubsubProtocol.Gossip_V_1_3.supportsBackoffAndPX()).isTrue() + assertThat(PubsubProtocol.Floodsub.supportsBackoffAndPX()).isFalse() + } + + @Test + fun `supportsIDontWant is true for all GossipSub versions from v1_2 onwards`() { + assertThat(PubsubProtocol.Gossip_V_1_0.supportsIDontWant()).isFalse() + assertThat(PubsubProtocol.Gossip_V_1_1.supportsIDontWant()).isFalse() + assertThat(PubsubProtocol.Gossip_V_1_2.supportsIDontWant()).isTrue() + assertThat(PubsubProtocol.Gossip_V_1_3.supportsIDontWant()).isTrue() + assertThat(PubsubProtocol.Floodsub.supportsIDontWant()).isFalse() + } + + @Test + fun `supportsExtensions is true only for GossipSub v1_3`() { + assertThat(PubsubProtocol.Gossip_V_1_0.supportsExtensions()).isFalse() + assertThat(PubsubProtocol.Gossip_V_1_1.supportsExtensions()).isFalse() + assertThat(PubsubProtocol.Gossip_V_1_2.supportsExtensions()).isFalse() + assertThat(PubsubProtocol.Gossip_V_1_3.supportsExtensions()).isTrue() + assertThat(PubsubProtocol.Floodsub.supportsExtensions()).isFalse() + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_1Tests.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_1Tests.kt index 905a6b489..856c560ed 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_1Tests.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_1Tests.kt @@ -258,6 +258,57 @@ class GossipV1_1Tests : GossipTestsBase() { assertEquals(0, test.mockRouter.inboundMessages.size) } + @Test + fun testSubscribeRespectsBackoff() { + // Regression test for the subscribe()-bypasses-backoff bug. + // + // Reproduces the production failure mode where Teku, after being PRUNEd by a peer, + // re-subscribes to a topic (e.g., attestation subnet rotation) and immediately + // GRAFTs back onto peers that are still within their backoff window — accumulating + // P7 behaviour penalties on the remote scorer until disconnect. + // + // The heartbeat-driven mesh maintenance paths correctly filter by isBackOff; + // the subscribe() path historically did not. + val test = TwoRoutersTest() + + test.mockRouter.subscribe("topic1") + + // Let the mock peer's subscription propagate so it appears in topicPeers. + test.fuzz.timeController.addTime(1.seconds) + + // Mock peer pre-emptively PRUNEs us with a 30-second backoff before we subscribe. + val pruneMsg = Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().addPrune( + Rpc.ControlPrune.newBuilder() + .setTopicID("topic1") + .setBackoff(30) + ) + ).build() + test.mockRouter.sendToSingle(pruneMsg) + test.fuzz.timeController.addTime(100.millis) + test.mockRouter.inboundMessages.clear() + + // Now subscribe locally — this is the path that historically grafted without + // checking backoff. + test.gossipRouter.subscribe("topic1") + test.fuzz.timeController.addTime(15.seconds) + + // No GRAFT should have been sent while the backoff is active. + assertEquals( + 0, + test.mockRouter.inboundMessages + .count { it.hasControl() && it.control.graftCount > 0 }, + "subscribe() must not GRAFT a peer that is in backoff" + ) + + // After the backoff expires, the next heartbeat is allowed to GRAFT. + test.fuzz.timeController.addTime(20.seconds) + test.mockRouter.waitForMessage { + it.hasControl() && + it.control.graftCount > 0 && it.control.getGraft(0).topicID == "topic1" + } + } + @Test fun testGraftFloodPenalty() { val test = TwoRoutersTest() diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_3Tests.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_3Tests.kt index 72497b4d7..891e48cf7 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_3Tests.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipV1_3Tests.kt @@ -2,8 +2,11 @@ package io.libp2p.pubsub.gossip +import io.libp2p.etc.types.seconds import io.libp2p.pubsub.PubsubProtocol +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import pubsub.pb.Rpc class GossipV1_3Tests : GossipTestsBase() { @@ -16,4 +19,45 @@ class GossipV1_3Tests : GossipTestsBase() { test.gossipRouter.publish(msg) test.mockRouter.waitForMessage { it.publishCount > 0 } } + + @Test + fun testBackoffTimeoutOnV1_3() { + // Regression test: v1.3 must honor PRUNE backoff (inherited from v1.1). + // Previously `supportsBackoffAndPX()` only returned true for v1.1/v1.2, + // causing v1.3 routers to ignore the Backoff field and immediately re-GRAFT. + val test = TwoRoutersTest(protocol = PubsubProtocol.Gossip_V_1_3) + + test.mockRouter.subscribe("topic1") + test.gossipRouter.subscribe("topic1") + + // 2 heartbeats - the topic should be GRAFTed + test.fuzz.timeController.addTime(2.seconds) + test.mockRouter.waitForMessage { it.hasControl() && it.control.graftCount > 0 } + test.mockRouter.inboundMessages.clear() + + val pruneMsg = Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().addPrune( + Rpc.ControlPrune.newBuilder() + .setTopicID("topic1") + .setBackoff(30) + ) + ).build() + test.mockRouter.sendToSingle(pruneMsg) + + // No GRAFT should be sent during the backoff window + test.fuzz.timeController.addTime(15.seconds) + assertEquals( + 0, + test.mockRouter.inboundMessages + .count { it.hasControl() && it.control.graftCount > 0 } + ) + test.mockRouter.inboundMessages.clear() + + // Expecting GRAFT after backoff expires + test.fuzz.timeController.addTime(20.seconds) + test.mockRouter.waitForMessage { + it.hasControl() && + it.control.graftCount > 0 && it.control.getGraft(0).topicID == "topic1" + } + } } From c34cc114950a7af352d30ea63c70d98795fd9aaa Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Wed, 20 May 2026 11:55:31 +1200 Subject: [PATCH 29/30] Add PubsubRpcLimits decoding count-cap to reduce memory footprint (#477) --- .../kotlin/io/libp2p/pubsub/AbstractRouter.kt | 10 + .../io/libp2p/pubsub/PubsubRpcLimits.kt | 60 ++++ .../io/libp2p/pubsub/RpcCountFrameDecoder.kt | 59 ++++ .../libp2p/pubsub/RpcMessageCountValidator.kt | 266 ++++++++++++++++ .../io/libp2p/pubsub/gossip/GossipRouter.kt | 17 + .../pubsub/PubsubRpcLimitsDefaultTest.kt | 26 ++ .../io/libp2p/pubsub/PubsubRpcLimitsTest.kt | 42 +++ .../pubsub/RpcCountFrameDecoderAttackTest.kt | 82 +++++ .../libp2p/pubsub/RpcCountFrameDecoderTest.kt | 112 +++++++ ...cMessageCountValidatorProtoCoverageTest.kt | 65 ++++ .../pubsub/RpcMessageCountValidatorTest.kt | 296 ++++++++++++++++++ 11 files changed, 1035 insertions(+) create mode 100644 libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubRpcLimits.kt create mode 100644 libp2p/src/main/kotlin/io/libp2p/pubsub/RpcCountFrameDecoder.kt create mode 100644 libp2p/src/main/kotlin/io/libp2p/pubsub/RpcMessageCountValidator.kt create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubRpcLimitsDefaultTest.kt create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubRpcLimitsTest.kt create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/RpcCountFrameDecoderAttackTest.kt create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/RpcCountFrameDecoderTest.kt create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/RpcMessageCountValidatorProtoCoverageTest.kt create mode 100644 libp2p/src/test/kotlin/io/libp2p/pubsub/RpcMessageCountValidatorTest.kt diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt index a72b93ccf..999bc2532 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt @@ -83,6 +83,15 @@ abstract class AbstractRouter( return true } + /** + * Per-router caps on repeated-field counts inside inbound RPCs. Enforced before + * protobuf materialisation by an [RpcCountFrameDecoder] inserted into the stream + * pipeline. Defaults to [PubsubRpcLimits.NONE] (no pre-decode cap). Subclasses + * with configured limits (e.g. [io.libp2p.pubsub.gossip.GossipRouter]) override. + */ + protected open val rpcLimits: PubsubRpcLimits + get() = PubsubRpcLimits.NONE + /** * Flushes all pending message parts for all peers */ @@ -113,6 +122,7 @@ abstract class AbstractRouter( with(streamHandler.stream) { pushHandler(LimitedProtobufVarint32FrameDecoder(maxMsgSize)) pushHandler(ProtobufVarint32LengthFieldPrepender()) + pushHandler(RpcCountFrameDecoder(rpcLimits)) pushHandler(ProtobufDecoder(Rpc.RPC.getDefaultInstance())) pushHandler(ProtobufEncoder()) handler?.also { pushHandler(it) } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubRpcLimits.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubRpcLimits.kt new file mode 100644 index 000000000..2c9e7f848 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubRpcLimits.kt @@ -0,0 +1,60 @@ +package io.libp2p.pubsub + +/** + * Per-router limits on repeated-field counts inside an inbound pubsub RPC. Enforced + * at decode time by [RpcMessageCountValidator] to prevent allocation amplification + * before [pubsub.pb.Rpc.RPC] is materialised. + * + * A null field means "no limit" — same semantics as the corresponding nullable + * fields on `GossipParams`. + */ +data class PubsubRpcLimits( + val maxPublishedMessages: Int?, + val maxTopicsPerPublishedMessage: Int?, + val maxSubscriptions: Int?, + val maxIHaveMessageIds: Int?, + val maxIWantMessageIds: Int?, + val maxGraftMessages: Int?, + val maxPruneMessages: Int?, + val maxPeersPerPruneMessage: Int?, + val maxIDontWantMessages: Int? = null, + val maxIDontWantMessageIds: Int? = null, + val rejectEmptyPublishEntries: Boolean = true, + val rejectEmptyIDontWantEntries: Boolean = true, +) { + /** + * True when no configured limit or reject-flag can fire. Lets + * [RpcCountFrameDecoder] skip the validator walk entirely on the toggle-off + * path. Any new field added to this data class must be considered here. + */ + val isNoop: Boolean = + maxPublishedMessages == null && + maxTopicsPerPublishedMessage == null && + maxSubscriptions == null && + maxIHaveMessageIds == null && + maxIWantMessageIds == null && + maxGraftMessages == null && + maxPruneMessages == null && + maxPeersPerPruneMessage == null && + maxIDontWantMessages == null && + maxIDontWantMessageIds == null && + !rejectEmptyPublishEntries && + !rejectEmptyIDontWantEntries + + companion object { + val NONE = PubsubRpcLimits( + maxPublishedMessages = null, + maxTopicsPerPublishedMessage = null, + maxSubscriptions = null, + maxIHaveMessageIds = null, + maxIWantMessageIds = null, + maxGraftMessages = null, + maxPruneMessages = null, + maxPeersPerPruneMessage = null, + maxIDontWantMessages = null, + maxIDontWantMessageIds = null, + rejectEmptyPublishEntries = false, + rejectEmptyIDontWantEntries = false, + ) + } +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcCountFrameDecoder.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcCountFrameDecoder.kt new file mode 100644 index 000000000..45b108f23 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcCountFrameDecoder.kt @@ -0,0 +1,59 @@ +package io.libp2p.pubsub + +import io.netty.buffer.ByteBuf +import io.netty.channel.ChannelHandlerContext +import io.netty.handler.codec.CorruptedFrameException +import io.netty.handler.codec.MessageToMessageDecoder +import org.slf4j.LoggerFactory + +/** + * Pre-decode count cap for inbound pubsub RPC frames. Sits between + * [io.libp2p.etc.util.netty.protobuf.LimitedProtobufVarint32FrameDecoder] (byte-size + * cap) and [io.netty.handler.codec.protobuf.ProtobufDecoder] (materialisation). + * + * For each frame, delegates to [RpcMessageCountValidator]. Accepted frames are + * forwarded unchanged as a `ByteBuf` to the next handler. Frames rejected because + * a configured count limit was exceeded are dropped with a debug log; no + * `Rpc$Message` is allocated for them. Frames rejected because the protobuf bytes + * themselves are malformed propagate a [CorruptedFrameException] so that + * downstream handlers (e.g. [io.libp2p.pubsub.AbstractRouter.onPeerWireException]) + * can apply the same behaviour penalty they would have on a [ProtobufDecoder] + * failure. + * + * When [limits] is a no-op (see [PubsubRpcLimits.isNoop], e.g. [PubsubRpcLimits.NONE]) + * the validator is skipped entirely and the buffer is forwarded as-is. Malformed + * bytes still surface downstream from [ProtobufDecoder], which already triggers + * the same wire-exception path the validator would have used. + */ +class RpcCountFrameDecoder(private val limits: PubsubRpcLimits) : MessageToMessageDecoder() { + + override fun decode(ctx: ChannelHandlerContext, msg: ByteBuf, out: MutableList) { + if (limits.isNoop) { + out.add(msg.retain()) + return + } + + val result = try { + RpcMessageCountValidator.validate(msg, limits) + } catch (e: Exception) { + logger.debug("Dropping pubsub RPC frame due to unexpected validator error", e) + return + } + + when (result) { + RpcMessageCountValidator.Result.Accepted -> { + out.add(msg.retain()) + } + is RpcMessageCountValidator.Result.Malformed -> { + throw CorruptedFrameException(result.reason) + } + is RpcMessageCountValidator.Result.Rejected -> { + logger.debug("Dropping pubsub RPC frame: {}", result.reason) + } + } + } + + companion object { + private val logger = LoggerFactory.getLogger(RpcCountFrameDecoder::class.java) + } +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcMessageCountValidator.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcMessageCountValidator.kt new file mode 100644 index 000000000..179abbeb8 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcMessageCountValidator.kt @@ -0,0 +1,266 @@ +package io.libp2p.pubsub + +import com.google.protobuf.CodedInputStream +import com.google.protobuf.Descriptors +import com.google.protobuf.WireFormat +import io.netty.buffer.ByteBuf +import pubsub.pb.Rpc +import java.io.IOException + +/** + * Walks an inbound pubsub RPC [ByteBuf] without materialising any `pubsub.pb.Rpc` + * message and rejects it if its repeated-field counts violate [PubsubRpcLimits]. + * + * Field numbers are taken from the protobuf-generated `*_FIELD_NUMBER` constants, + * so renames in `libp2p/src/main/proto/rpc.proto` break compilation. New repeated + * fields are caught by `RpcMessageCountValidatorProtoCoverageTest`, which + * recursively walks the descriptors reachable from [Rpc.RPC] and asserts each one + * appears in [ACKNOWLEDGED_REPEATED_FIELDS]. + * + * The walker uses [CodedInputStream] to read tags / lengths and to skip bodies, + * so no `Rpc$Message` / builder is allocated for rejected frames. + */ +object RpcMessageCountValidator { + + sealed interface Result { + object Accepted : Result + data class Rejected(val reason: String) : Result + data class Malformed(val reason: String) : Result + } + + // pubsub.RPC field numbers + private const val RPC_SUBSCRIPTIONS = Rpc.RPC.SUBSCRIPTIONS_FIELD_NUMBER + private const val RPC_PUBLISH = Rpc.RPC.PUBLISH_FIELD_NUMBER + private const val RPC_CONTROL = Rpc.RPC.CONTROL_FIELD_NUMBER + + // pubsub.Message field numbers + private const val MESSAGE_TOPIC_IDS = Rpc.Message.TOPICIDS_FIELD_NUMBER + + // pubsub.ControlMessage field numbers + private const val CTRL_IHAVE = Rpc.ControlMessage.IHAVE_FIELD_NUMBER + private const val CTRL_IWANT = Rpc.ControlMessage.IWANT_FIELD_NUMBER + private const val CTRL_GRAFT = Rpc.ControlMessage.GRAFT_FIELD_NUMBER + private const val CTRL_PRUNE = Rpc.ControlMessage.PRUNE_FIELD_NUMBER + private const val CTRL_IDONTWANT = Rpc.ControlMessage.IDONTWANT_FIELD_NUMBER + + // pubsub.ControlIHave / ControlIWant / ControlIDontWant repeated bytes field numbers + private const val IHAVE_MESSAGE_IDS = Rpc.ControlIHave.MESSAGEIDS_FIELD_NUMBER + private const val IWANT_MESSAGE_IDS = Rpc.ControlIWant.MESSAGEIDS_FIELD_NUMBER + private const val IDONTWANT_MESSAGE_IDS = Rpc.ControlIDontWant.MESSAGEIDS_FIELD_NUMBER + + // pubsub.ControlPrune.peers + private const val PRUNE_PEERS = Rpc.ControlPrune.PEERS_FIELD_NUMBER + + /** + * Single source of truth for every repeated proto field the validator inspects. + * The proto-coverage test asserts this map equals the set of repeated fields + * actually present in the proto, recursively from [Rpc.RPC]. Any new repeated + * field that lands in `rpc.proto` without being added here will fail the test. + */ + internal val ACKNOWLEDGED_REPEATED_FIELDS: Map> = mapOf( + Rpc.RPC.getDescriptor() to setOf(RPC_SUBSCRIPTIONS, RPC_PUBLISH), + Rpc.Message.getDescriptor() to setOf(MESSAGE_TOPIC_IDS), + Rpc.ControlMessage.getDescriptor() to setOf( + CTRL_IHAVE, + CTRL_IWANT, + CTRL_GRAFT, + CTRL_PRUNE, + CTRL_IDONTWANT + ), + Rpc.ControlIHave.getDescriptor() to setOf(IHAVE_MESSAGE_IDS), + Rpc.ControlIWant.getDescriptor() to setOf(IWANT_MESSAGE_IDS), + Rpc.ControlIDontWant.getDescriptor() to setOf(IDONTWANT_MESSAGE_IDS), + Rpc.ControlPrune.getDescriptor() to setOf(PRUNE_PEERS), + ) + + fun validate(buf: ByteBuf, limits: PubsubRpcLimits): Result { + val input = CodedInputStream.newInstance(buf.nioBuffer()) + return try { + validateRpc(input, limits) + } catch (e: IOException) { + Result.Malformed("malformed: ${e.message}") + } catch (e: IndexOutOfBoundsException) { + Result.Malformed("malformed: truncated (${e.message})") + } + } + + private class ControlCounters { + var ihaveMsgIds = 0 + var iwantMsgIds = 0 + var graftCount = 0 + var pruneCount = 0 + var idontwantCount = 0 + var idontwantMsgIds = 0 + } + + private fun validateRpc(input: CodedInputStream, limits: PubsubRpcLimits): Result { + var publishCount = 0 + var subscriptionCount = 0 + val ctrl = ControlCounters() + + while (!input.isAtEnd) { + val tag = input.readTag() + val fieldNumber = WireFormat.getTagFieldNumber(tag) + val wireType = WireFormat.getTagWireType(tag) + when { + fieldNumber == RPC_SUBSCRIPTIONS && + wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + subscriptionCount++ + limits.maxSubscriptions?.let { + if (subscriptionCount > it) return Result.Rejected("subscriptions count > $it") + } + input.skipField(tag) + } + fieldNumber == RPC_PUBLISH && + wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readRawVarint32() + if (length == 0 && limits.rejectEmptyPublishEntries) { + return Result.Rejected("empty publish entry") + } + publishCount++ + limits.maxPublishedMessages?.let { + if (publishCount > it) return Result.Rejected("publish count > $it") + } + val oldLimit = input.pushLimit(length) + val maxTopics = limits.maxTopicsPerPublishedMessage + if (maxTopics != null) { + val res = validatePublish(input, maxTopics) + if (res is Result.Rejected) return res + } else { + input.skipMessage() + } + input.popLimit(oldLimit) + } + fieldNumber == RPC_CONTROL && + wireType == WireFormat.WIRETYPE_LENGTH_DELIMITED -> { + val length = input.readRawVarint32() + val oldLimit = input.pushLimit(length) + val res = validateControl(input, limits, ctrl) + if (res is Result.Rejected) return res + input.popLimit(oldLimit) + } + else -> input.skipField(tag) + } + } + return Result.Accepted + } + + private fun validatePublish(input: CodedInputStream, maxTopics: Int): Result { + var topicCount = 0 + while (!input.isAtEnd) { + val tag = input.readTag() + if (WireFormat.getTagFieldNumber(tag) == MESSAGE_TOPIC_IDS && + WireFormat.getTagWireType(tag) == WireFormat.WIRETYPE_LENGTH_DELIMITED + ) { + topicCount++ + if (topicCount > maxTopics) return Result.Rejected("topicIDs per publish > $maxTopics") + input.skipField(tag) + } else { + input.skipField(tag) + } + } + return Result.Accepted + } + + private fun validateControl( + input: CodedInputStream, + limits: PubsubRpcLimits, + c: ControlCounters, + ): Result { + while (!input.isAtEnd) { + val tag = input.readTag() + val fieldNumber = WireFormat.getTagFieldNumber(tag) + val wireType = WireFormat.getTagWireType(tag) + if (wireType != WireFormat.WIRETYPE_LENGTH_DELIMITED) { + input.skipField(tag) + continue + } + when (fieldNumber) { + CTRL_IHAVE -> { + val length = input.readRawVarint32() + val oldLimit = input.pushLimit(length) + val count = countRepeatedBytes(input, IHAVE_MESSAGE_IDS) + c.ihaveMsgIds += count + limits.maxIHaveMessageIds?.let { + if (c.ihaveMsgIds > it) return Result.Rejected("ihave messageIDs > $it") + } + input.popLimit(oldLimit) + } + CTRL_IWANT -> { + val length = input.readRawVarint32() + val oldLimit = input.pushLimit(length) + val count = countRepeatedBytes(input, IWANT_MESSAGE_IDS) + c.iwantMsgIds += count + limits.maxIWantMessageIds?.let { + if (c.iwantMsgIds > it) return Result.Rejected("iwant messageIDs > $it") + } + input.popLimit(oldLimit) + } + CTRL_GRAFT -> { + c.graftCount++ + limits.maxGraftMessages?.let { + if (c.graftCount > it) return Result.Rejected("graft count > $it") + } + input.skipField(tag) + } + CTRL_PRUNE -> { + c.pruneCount++ + limits.maxPruneMessages?.let { + if (c.pruneCount > it) return Result.Rejected("prune count > $it") + } + val length = input.readRawVarint32() + val oldLimit = input.pushLimit(length) + val maxPeers = limits.maxPeersPerPruneMessage + if (maxPeers != null) { + val peerCount = countRepeatedMessages(input, PRUNE_PEERS) + if (peerCount > maxPeers) return Result.Rejected("peers per prune > $maxPeers") + } else { + input.skipMessage() + } + input.popLimit(oldLimit) + } + CTRL_IDONTWANT -> { + c.idontwantCount++ + limits.maxIDontWantMessages?.let { + if (c.idontwantCount > it) return Result.Rejected("idontwant count > $it") + } + val length = input.readRawVarint32() + if (length == 0 && limits.rejectEmptyIDontWantEntries) { + return Result.Rejected("empty idontwant entry") + } + val oldLimit = input.pushLimit(length) + val count = countRepeatedBytes(input, IDONTWANT_MESSAGE_IDS) + c.idontwantMsgIds += count + limits.maxIDontWantMessageIds?.let { + if (c.idontwantMsgIds > it) return Result.Rejected("idontwant messageIDs > $it") + } + input.popLimit(oldLimit) + } + else -> input.skipField(tag) + } + } + return Result.Accepted + } + + /** + * Counts occurrences of a length-delimited repeated field inside a sub-message + * region. The [CodedInputStream] must already be bounded by a `pushLimit` on the + * caller side; this method walks until `isAtEnd` and skips every body. + */ + private fun countRepeatedBytes(input: CodedInputStream, fieldNumber: Int): Int { + var count = 0 + while (!input.isAtEnd) { + val tag = input.readTag() + if (WireFormat.getTagFieldNumber(tag) == fieldNumber && + WireFormat.getTagWireType(tag) == WireFormat.WIRETYPE_LENGTH_DELIMITED + ) { + count++ + } + input.skipField(tag) + } + return count + } + + private fun countRepeatedMessages(input: CodedInputStream, fieldNumber: Int): Int = + countRepeatedBytes(input, fieldNumber) +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt index 33271e190..6b4551f97 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt @@ -7,6 +7,7 @@ import io.libp2p.core.pubsub.ValidationResult import io.libp2p.etc.types.* import io.libp2p.etc.util.P2PService import io.libp2p.pubsub.* +import io.libp2p.pubsub.PubsubRpcLimits import org.slf4j.LoggerFactory import pubsub.pb.Rpc import java.time.Duration @@ -256,6 +257,22 @@ open class GossipRouter( return peerScore >= scoreParams.graylistThreshold } + override val rpcLimits: PubsubRpcLimits by lazy { + PubsubRpcLimits( + maxPublishedMessages = params.maxPublishedMessages, + maxTopicsPerPublishedMessage = params.maxTopicsPerPublishedMessage, + maxSubscriptions = params.maxSubscriptions, + maxIHaveMessageIds = params.maxIHaveLength, + maxIWantMessageIds = params.maxIWantMessageIds, + maxGraftMessages = params.maxGraftMessages, + maxPruneMessages = params.maxPruneMessages, + maxPeersPerPruneMessage = params.maxPeersAcceptedInPruneMsg, + maxIDontWantMessageIds = params.maxIDontWantMessageIds, + rejectEmptyPublishEntries = true, + rejectEmptyIDontWantEntries = true, + ) + } + override fun validateMessageListLimits(msg: Rpc.RPCOrBuilder): Boolean { val iWantMessageIdCount = msg.control?.iwantList?.sumOf { w -> w.messageIDsCount } ?: 0 val iHaveMessageIdCount = msg.control?.ihaveList?.sumOf { w -> w.messageIDsCount } ?: 0 diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubRpcLimitsDefaultTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubRpcLimitsDefaultTest.kt new file mode 100644 index 000000000..99a4342a2 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubRpcLimitsDefaultTest.kt @@ -0,0 +1,26 @@ +package io.libp2p.pubsub + +import io.libp2p.pubsub.flood.FloodRouter +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * Pins the toggle-off contract for the inbound count-validation pipeline: any + * [AbstractRouter] subclass that does not opt in must observe + * [PubsubRpcLimits.NONE], so its wire behaviour is unchanged by this defence. + * + * Uses reflection because `rpcLimits` is `protected` and [FloodRouter] is `final`. + */ +class PubsubRpcLimitsDefaultTest { + + @Test + fun `FloodRouter inherits NONE rpcLimits from AbstractRouter`() { + assertThat(FloodRouter().readRpcLimits()).isEqualTo(PubsubRpcLimits.NONE) + } + + private fun AbstractRouter.readRpcLimits(): PubsubRpcLimits { + val getter = AbstractRouter::class.java.getDeclaredMethod("getRpcLimits") + getter.isAccessible = true + return getter.invoke(this) as PubsubRpcLimits + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubRpcLimitsTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubRpcLimitsTest.kt new file mode 100644 index 000000000..573bddb11 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/PubsubRpcLimitsTest.kt @@ -0,0 +1,42 @@ +package io.libp2p.pubsub + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class PubsubRpcLimitsTest { + + @Test + fun `NONE is a noop`() { + assertThat(PubsubRpcLimits.NONE.isNoop).isTrue + } + + @ParameterizedTest(name = "non-noop when {0}") + @MethodSource("nonNoopMutations") + fun `any configured limit or reject flag makes isNoop false`( + @Suppress("UNUSED_PARAMETER") label: String, + mutated: PubsubRpcLimits, + ) { + assertThat(mutated.isNoop).isFalse + } + + companion object { + @JvmStatic + fun nonNoopMutations(): List = listOf( + Arguments.of("maxPublishedMessages set", PubsubRpcLimits.NONE.copy(maxPublishedMessages = 1)), + Arguments.of("maxTopicsPerPublishedMessage set", PubsubRpcLimits.NONE.copy(maxTopicsPerPublishedMessage = 1)), + Arguments.of("maxSubscriptions set", PubsubRpcLimits.NONE.copy(maxSubscriptions = 1)), + Arguments.of("maxIHaveMessageIds set", PubsubRpcLimits.NONE.copy(maxIHaveMessageIds = 1)), + Arguments.of("maxIWantMessageIds set", PubsubRpcLimits.NONE.copy(maxIWantMessageIds = 1)), + Arguments.of("maxGraftMessages set", PubsubRpcLimits.NONE.copy(maxGraftMessages = 1)), + Arguments.of("maxPruneMessages set", PubsubRpcLimits.NONE.copy(maxPruneMessages = 1)), + Arguments.of("maxPeersPerPruneMessage set", PubsubRpcLimits.NONE.copy(maxPeersPerPruneMessage = 1)), + Arguments.of("maxIDontWantMessages set", PubsubRpcLimits.NONE.copy(maxIDontWantMessages = 1)), + Arguments.of("maxIDontWantMessageIds set", PubsubRpcLimits.NONE.copy(maxIDontWantMessageIds = 1)), + Arguments.of("rejectEmptyPublishEntries=true", PubsubRpcLimits.NONE.copy(rejectEmptyPublishEntries = true)), + Arguments.of("rejectEmptyIDontWantEntries=true", PubsubRpcLimits.NONE.copy(rejectEmptyIDontWantEntries = true)), + ) + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcCountFrameDecoderAttackTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcCountFrameDecoderAttackTest.kt new file mode 100644 index 000000000..e6bd08a7c --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcCountFrameDecoderAttackTest.kt @@ -0,0 +1,82 @@ +package io.libp2p.pubsub + +import io.libp2p.etc.util.netty.protobuf.LimitedProtobufVarint32FrameDecoder +import io.netty.buffer.ByteBufAllocator +import io.netty.channel.embedded.EmbeddedChannel +import io.netty.handler.codec.protobuf.ProtobufDecoder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +class RpcCountFrameDecoderAttackTest { + + private val limits = PubsubRpcLimits.NONE.copy( + maxPublishedMessages = 1000, + rejectEmptyPublishEntries = true, + ) + + @Test + fun `attack frame of empty publish entries is rejected before materialisation`() { + val maxMsgSize = 12_234_442 // Teku mainnet-preset + + val ch = EmbeddedChannel( + LimitedProtobufVarint32FrameDecoder(maxMsgSize), + RpcCountFrameDecoder(limits), + ProtobufDecoder(Rpc.RPC.getDefaultInstance()), + ) + + // 100_000 empty publish entries: large enough to demonstrate amplification + // would be catastrophic, small enough to keep the test cheap. + val entries = 100_000 + val body = ByteArray(entries * 2) { if (it % 2 == 0) 0x12.toByte() else 0x00.toByte() } + + // Write a length-prefixed frame manually: varint(length) || body. + val framed = ByteBufAllocator.DEFAULT.buffer(body.size + 5) + writeVarint32(framed, body.size) + framed.writeBytes(body) + + ch.writeInbound(framed) + + val received: Any? = ch.readInbound() + assertThat(received).isNull() // ProtobufDecoder never produced an Rpc.RPC + } + + @Test + fun `well-formed RPC under the same limits is still delivered`() { + val maxMsgSize = 12_234_442 + + val ch = EmbeddedChannel( + LimitedProtobufVarint32FrameDecoder(maxMsgSize), + RpcCountFrameDecoder(limits), + ProtobufDecoder(Rpc.RPC.getDefaultInstance()), + ) + + val rpc = Rpc.RPC.newBuilder() + .addPublish( + Rpc.Message.newBuilder().setData(com.google.protobuf.ByteString.copyFromUtf8("ok")) + ) + .build() + val body = rpc.toByteArray() + + val framed = ByteBufAllocator.DEFAULT.buffer(body.size + 5) + writeVarint32(framed, body.size) + framed.writeBytes(body) + + ch.writeInbound(framed) + + val received: Rpc.RPC? = ch.readInbound() + assertThat(received).isEqualTo(rpc) + } + + private fun writeVarint32(buf: io.netty.buffer.ByteBuf, value: Int) { + var v = value + while (true) { + if (v and 0x7F.inv() == 0) { + buf.writeByte(v) + return + } + buf.writeByte((v and 0x7F) or 0x80) + v = v ushr 7 + } + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcCountFrameDecoderTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcCountFrameDecoderTest.kt new file mode 100644 index 000000000..007faf247 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcCountFrameDecoderTest.kt @@ -0,0 +1,112 @@ +package io.libp2p.pubsub + +import com.google.protobuf.ByteString +import io.netty.buffer.ByteBuf +import io.netty.buffer.Unpooled +import io.netty.channel.embedded.EmbeddedChannel +import io.netty.handler.codec.protobuf.ProtobufDecoder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +class RpcCountFrameDecoderTest { + + private val limits = PubsubRpcLimits.NONE.copy( + maxPublishedMessages = 2, + rejectEmptyPublishEntries = true, + ) + + private fun pipeline() = EmbeddedChannel( + RpcCountFrameDecoder(limits), + ProtobufDecoder(Rpc.RPC.getDefaultInstance()), + ) + + @Test + fun `forwards an accepted RPC unchanged`() { + val ch = pipeline() + val rpc = Rpc.RPC.newBuilder() + .addPublish(Rpc.Message.newBuilder().setData(ByteString.copyFromUtf8("x"))) + .build() + + ch.writeInbound(Unpooled.wrappedBuffer(rpc.toByteArray())) + + val received: Rpc.RPC? = ch.readInbound() + assertThat(received).isEqualTo(rpc) + } + + @Test + fun `drops an RPC containing an empty publish entry`() { + val ch = pipeline() + val rpc = Rpc.RPC.newBuilder() + .addPublish(Rpc.Message.getDefaultInstance()) + .build() + + ch.writeInbound(Unpooled.wrappedBuffer(rpc.toByteArray())) + + val received: Any? = ch.readInbound() + assertThat(received).isNull() + } + + @Test + fun `drops an RPC whose publish count exceeds limits`() { + val ch = pipeline() + val rpc = Rpc.RPC.newBuilder() + .apply { + repeat(3) { + addPublish(Rpc.Message.newBuilder().setData(ByteString.copyFromUtf8("x$it"))) + } + } + .build() + + ch.writeInbound(Unpooled.wrappedBuffer(rpc.toByteArray())) + + val received: Any? = ch.readInbound() + assertThat(received).isNull() + } + + /** + * Toggle-off guarantee: with [PubsubRpcLimits.NONE], a frame that would be rejected + * under tighter limits (empty publish entry plus an extra publish over the cap above) + * must pass through the decoder unchanged. + */ + @Test + fun `forwards an otherwise-rejectable RPC when limits are NONE`() { + val ch = EmbeddedChannel( + RpcCountFrameDecoder(PubsubRpcLimits.NONE), + ProtobufDecoder(Rpc.RPC.getDefaultInstance()), + ) + val rpc = Rpc.RPC.newBuilder() + .addPublish(Rpc.Message.getDefaultInstance()) + .apply { + repeat(3) { + addPublish(Rpc.Message.newBuilder().setData(ByteString.copyFromUtf8("x$it"))) + } + } + .build() + + ch.writeInbound(Unpooled.wrappedBuffer(rpc.toByteArray())) + + val received: Rpc.RPC? = ch.readInbound() + assertThat(received).isEqualTo(rpc) + } + + /** + * Fast-path proof: a truncated frame would be flagged `Malformed` by + * [RpcMessageCountValidator] and converted to a [CorruptedFrameException] + * by the decoder. With [PubsubRpcLimits.NONE] the validator must be skipped + * entirely, so the truncated bytes pass through to the next handler unchanged. + */ + @Test + fun `skips validator when limits are noop`() { + val ch = EmbeddedChannel(RpcCountFrameDecoder(PubsubRpcLimits.NONE)) + val rpc = Rpc.RPC.newBuilder() + .addPublish(Rpc.Message.newBuilder().setData(ByteString.copyFromUtf8("x"))) + .build() + val truncated = rpc.toByteArray().copyOfRange(0, rpc.toByteArray().size - 1) + + ch.writeInbound(Unpooled.wrappedBuffer(truncated)) + + val received: Any? = ch.readInbound() + assertThat(received).isInstanceOf(ByteBuf::class.java) + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcMessageCountValidatorProtoCoverageTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcMessageCountValidatorProtoCoverageTest.kt new file mode 100644 index 000000000..1387982fc --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcMessageCountValidatorProtoCoverageTest.kt @@ -0,0 +1,65 @@ +package io.libp2p.pubsub + +import com.google.protobuf.Descriptors +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +/** + * Bit-rot guard: any new repeated proto field added to `rpc.proto` must be + * explicitly acknowledged in [RpcMessageCountValidator.ACKNOWLEDGED_REPEATED_FIELDS], + * forcing the author to consider whether it needs a count limit. + * + * Walks every message descriptor reachable from [Rpc.RPC] and compares the + * repeated-field set against the validator's acknowledged set per descriptor. + */ +class RpcMessageCountValidatorProtoCoverageTest { + + @Test + fun `every repeated field reachable from RPC is acknowledged by the validator`() { + val reachable = reachableDescriptors(Rpc.RPC.getDescriptor()) + val expected: Map> = reachable + .associateWith { d -> d.fields.filter { it.isRepeated }.map { it.number }.toSet() } + .filterValues { it.isNotEmpty() } + + val actual = RpcMessageCountValidator.ACKNOWLEDGED_REPEATED_FIELDS + + expected.forEach { (descriptor, expectedFields) -> + val actualFields = actual[descriptor] ?: emptySet() + assertThat(actualFields) + .describedAs( + "Repeated fields in %s must be acknowledged in " + + "RpcMessageCountValidator.ACKNOWLEDGED_REPEATED_FIELDS. " + + "Add new fields (and the corresponding decode-time guard) " + + "before merging.", + descriptor.fullName, + ) + .isEqualTo(expectedFields) + } + + val stale = actual.keys - expected.keys + assertThat(stale) + .describedAs( + "Stale entries in ACKNOWLEDGED_REPEATED_FIELDS — these descriptors " + + "are no longer reachable from Rpc.RPC or no longer contain repeated " + + "fields: %s", + stale.map { it.fullName }, + ) + .isEmpty() + } + + private fun reachableDescriptors(root: Descriptors.Descriptor): Set { + val seen = LinkedHashSet() + val stack: MutableList = mutableListOf(root) + while (stack.isNotEmpty()) { + val descriptor = stack.removeAt(stack.lastIndex) + if (!seen.add(descriptor)) continue + descriptor.fields.forEach { field -> + if (field.javaType == Descriptors.FieldDescriptor.JavaType.MESSAGE) { + stack.add(field.messageType) + } + } + } + return seen + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcMessageCountValidatorTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcMessageCountValidatorTest.kt new file mode 100644 index 000000000..5ad433474 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/RpcMessageCountValidatorTest.kt @@ -0,0 +1,296 @@ +package io.libp2p.pubsub + +import com.google.protobuf.ByteString +import io.netty.buffer.Unpooled +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +class RpcMessageCountValidatorTest { + + private val unlimited = PubsubRpcLimits.NONE.copy(rejectEmptyPublishEntries = true) + + private fun bytesOf(rpc: Rpc.RPC) = Unpooled.wrappedBuffer(rpc.toByteArray()) + + private fun message(topics: Int = 0): Rpc.Message { + val b = Rpc.Message.newBuilder().setData(ByteString.copyFromUtf8("x")) + repeat(topics) { b.addTopicIDs("t$it") } + return b.build() + } + + private fun subOpt(topic: String) = + Rpc.RPC.SubOpts.newBuilder().setTopicid(topic).setSubscribe(true).build() + + private fun ihave(ids: Int) = Rpc.ControlIHave.newBuilder() + .setTopicID("t") + .also { repeat(ids) { i -> it.addMessageIDs(ByteString.copyFromUtf8("m$i")) } } + .build() + + private fun iwant(ids: Int) = Rpc.ControlIWant.newBuilder() + .also { repeat(ids) { i -> it.addMessageIDs(ByteString.copyFromUtf8("m$i")) } } + .build() + + private fun idontwant(ids: Int) = Rpc.ControlIDontWant.newBuilder() + .also { repeat(ids) { i -> it.addMessageIDs(ByteString.copyFromUtf8("m$i")) } } + .build() + + private fun pruneWithPeers(peers: Int) = Rpc.ControlPrune.newBuilder() + .setTopicID("t") + .also { + repeat(peers) { i -> + it.addPeers(Rpc.PeerInfo.newBuilder().setPeerID(ByteString.copyFromUtf8("p$i"))) + } + } + .build() + + @Test + fun `rejects RPC containing an empty publish entry`() { + val rpc = Rpc.RPC.newBuilder() + .addPublish(Rpc.Message.getDefaultInstance()) + .build() + + val result = RpcMessageCountValidator.validate(bytesOf(rpc), unlimited) + + assertThat(result).isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `accepts non-empty publish when allowed`() { + val rpc = Rpc.RPC.newBuilder().addPublish(message(topics = 1)).build() + val limits = PubsubRpcLimits.NONE + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isEqualTo(RpcMessageCountValidator.Result.Accepted) + } + + @Test + fun `rejects when publish count exceeds limit`() { + val rpc = Rpc.RPC.newBuilder() + .apply { repeat(3) { addPublish(message(topics = 1)) } } + .build() + val limits = PubsubRpcLimits.NONE.copy(maxPublishedMessages = 2) + val result = RpcMessageCountValidator.validate(bytesOf(rpc), limits) + assertThat(result).isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `rejects when subscriptions count exceeds limit`() { + val rpc = Rpc.RPC.newBuilder() + .apply { repeat(3) { addSubscriptions(subOpt("t$it")) } } + .build() + val limits = PubsubRpcLimits.NONE.copy(maxSubscriptions = 2) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `rejects when topicIDs per publish exceeds limit`() { + val rpc = Rpc.RPC.newBuilder().addPublish(message(topics = 5)).build() + val limits = PubsubRpcLimits.NONE.copy(maxTopicsPerPublishedMessage = 4) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `rejects when ihave messageIDs total exceeds limit`() { + val rpc = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .addIhave(ihave(ids = 4)) + .addIhave(ihave(ids = 4)) + ) + .build() + val limits = PubsubRpcLimits.NONE.copy(maxIHaveMessageIds = 7) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `rejects when iwant messageIDs total exceeds limit`() { + val rpc = Rpc.RPC.newBuilder() + .setControl(Rpc.ControlMessage.newBuilder().addIwant(iwant(ids = 10))) + .build() + val limits = PubsubRpcLimits.NONE.copy(maxIWantMessageIds = 9) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `rejects when graft count exceeds limit`() { + val rpc = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .addGraft(Rpc.ControlGraft.newBuilder().setTopicID("a")) + .addGraft(Rpc.ControlGraft.newBuilder().setTopicID("b")) + .addGraft(Rpc.ControlGraft.newBuilder().setTopicID("c")) + ) + .build() + val limits = PubsubRpcLimits.NONE.copy(maxGraftMessages = 2) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `rejects when prune count exceeds limit`() { + val rpc = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .addPrune(pruneWithPeers(peers = 0)) + .addPrune(pruneWithPeers(peers = 0)) + ) + .build() + val limits = PubsubRpcLimits.NONE.copy(maxPruneMessages = 1) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `rejects when peers per prune exceeds limit`() { + val rpc = Rpc.RPC.newBuilder() + .setControl(Rpc.ControlMessage.newBuilder().addPrune(pruneWithPeers(peers = 17))) + .build() + val limits = PubsubRpcLimits.NONE.copy(maxPeersPerPruneMessage = 16) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `rejects when idontwant messageIDs exceed limit`() { + val rpc = Rpc.RPC.newBuilder() + .setControl(Rpc.ControlMessage.newBuilder().addIdontwant(idontwant(ids = 5))) + .build() + val limits = PubsubRpcLimits.NONE.copy(maxIDontWantMessageIds = 4) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `accepts well-formed RPC under every configured limit`() { + val rpc = Rpc.RPC.newBuilder() + .addSubscriptions(subOpt("t")) + .addPublish(message(topics = 1)) + .setControl( + Rpc.ControlMessage.newBuilder() + .addIhave(ihave(ids = 2)) + .addIwant(iwant(ids = 2)) + .addGraft(Rpc.ControlGraft.newBuilder().setTopicID("t")) + .addPrune(pruneWithPeers(peers = 1)) + .addIdontwant(idontwant(ids = 1)) + ) + .build() + val limits = PubsubRpcLimits( + maxPublishedMessages = 10, + maxTopicsPerPublishedMessage = 4, + maxSubscriptions = 10, + maxIHaveMessageIds = 10, + maxIWantMessageIds = 10, + maxGraftMessages = 10, + maxPruneMessages = 10, + maxPeersPerPruneMessage = 10, + maxIDontWantMessages = 10, + maxIDontWantMessageIds = 10, + rejectEmptyPublishEntries = true, + ) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isEqualTo(RpcMessageCountValidator.Result.Accepted) + } + + @Test + fun `rejects truncated input as malformed`() { + val rpc = Rpc.RPC.newBuilder().addPublish(message(topics = 1)).build() + val full = rpc.toByteArray() + val truncated = full.copyOfRange(0, full.size - 1) + val result = RpcMessageCountValidator.validate(Unpooled.wrappedBuffer(truncated), unlimited) + assertThat(result).isInstanceOf(RpcMessageCountValidator.Result.Malformed::class.java) + } + + @Test + fun `attack payload of empty publish entries rejected on first entry`() { + // 1000 empty publish entries, which would expand to 1000 Rpc.Message objects. + val attack = ByteArray(2 * 1000) { if (it % 2 == 0) 0x12.toByte() else 0x00.toByte() } + val result = RpcMessageCountValidator.validate(Unpooled.wrappedBuffer(attack), unlimited) + assertThat(result).isEqualTo(RpcMessageCountValidator.Result.Rejected("empty publish entry")) + } + + @Test + fun `rejects when idontwant message count exceeds limit`() { + val rpc = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .addIdontwant(idontwant(ids = 1)) + .addIdontwant(idontwant(ids = 1)) + .addIdontwant(idontwant(ids = 1)) + ) + .build() + val limits = PubsubRpcLimits.NONE.copy(maxIDontWantMessages = 2) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `accepts when count equals limit exactly`() { + val rpc = Rpc.RPC.newBuilder() + .apply { repeat(3) { addPublish(message(topics = 1)) } } + .build() + val limits = PubsubRpcLimits.NONE.copy(maxPublishedMessages = 3) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isEqualTo(RpcMessageCountValidator.Result.Accepted) + } + + @Test + fun `rejects RPC containing an empty idontwant entry`() { + val rpc = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .addIdontwant(Rpc.ControlIDontWant.getDefaultInstance()) + ) + .build() + val limits = PubsubRpcLimits.NONE.copy(rejectEmptyIDontWantEntries = true) + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } + + @Test + fun `accepts empty idontwant entry when flag is off`() { + val rpc = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .addIdontwant(Rpc.ControlIDontWant.getDefaultInstance()) + ) + .build() + val limits = PubsubRpcLimits.NONE + assertThat(RpcMessageCountValidator.validate(bytesOf(rpc), limits)) + .isEqualTo(RpcMessageCountValidator.Result.Accepted) + } + + @Test + fun `rejects when graft count across split control fields exceeds limit`() { + // Build a frame with TWO top-level control fields manually, each containing + // 2 grafts. After protobuf merge the ControlMessage has 4 grafts, so a limit + // of 3 must reject. The validator must aggregate across both control fields. + val firstHalf = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .addGraft(Rpc.ControlGraft.newBuilder().setTopicID("a")) + .addGraft(Rpc.ControlGraft.newBuilder().setTopicID("b")) + ) + .build() + .toByteArray() + val secondHalf = Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .addGraft(Rpc.ControlGraft.newBuilder().setTopicID("c")) + .addGraft(Rpc.ControlGraft.newBuilder().setTopicID("d")) + ) + .build() + .toByteArray() + val combined = firstHalf + secondHalf + + // Sanity: the combined bytes parse to a merged ControlMessage with 4 grafts. + val parsed = Rpc.RPC.parseFrom(combined) + assertThat(parsed.control.graftCount).isEqualTo(4) + + val limits = PubsubRpcLimits.NONE.copy(maxGraftMessages = 3) + val result = RpcMessageCountValidator.validate(Unpooled.wrappedBuffer(combined), limits) + assertThat(result).isInstanceOf(RpcMessageCountValidator.Result.Rejected::class.java) + } +} From 2da63e469afe1de70618c231b12139d7ef39b617 Mon Sep 17 00:00:00 2001 From: StefanBratanov Date: Wed, 20 May 2026 09:56:52 +0100 Subject: [PATCH 30/30] 1.3.0 release --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7cfffb8e5..76aff4b51 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,7 +36,7 @@ configure( } ) { group = "io.libp2p" - version = "1.2.2-RELEASE" + version = "1.3.0-RELEASE" apply(plugin = "kotlin") apply(plugin = "idea")