From 151ce9451ee931b57689bb7ec6816c2603b361a0 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sun, 30 Nov 2025 02:54:12 -0800 Subject: [PATCH 01/22] Add transport-nethernet package Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .github/workflows/build.yml | 8 +- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 3 +- transport-nethernet/build.gradle.kts | 34 +++ .../channel/nethernet/NetherNetChannel.java | 274 +++++++++++++++++ .../nethernet/NetherNetChannelFactory.java | 32 ++ .../nethernet/NetherNetChildChannel.java | 30 ++ .../nethernet/NetherNetClientChannel.java | 283 ++++++++++++++++++ .../channel/nethernet/NetherNetConstants.java | 114 +++++++ .../nethernet/NetherNetServerChannel.java | 185 ++++++++++++ .../config/NetherNetChannelConfig.java | 34 +++ .../nethernet/config/package-info.java | 1 + .../netty/channel/nethernet/package-info.java | 1 + .../codec/nethernet/NetherNetDiscovery.java | 271 +++++++++++++++++ .../handler/codec/nethernet/package-info.java | 1 + .../util/nethernet/NetherNetScanner.java | 80 +++++ .../netty/util/nethernet/package-info.java | 1 + 18 files changed, 1353 insertions(+), 2 deletions(-) create mode 100644 transport-nethernet/build.gradle.kts create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetChannelConfig.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/package-info.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c6f8cc4..d7f4056 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,9 +11,15 @@ jobs: - uses: Kas-tle/NetworkCompatible/.github/setup-gradle-composite@master - name: Build run: ./gradlew build - - name: Archive Artifacts + - name: Archive Artifacts (transport-raknet) uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 with: name: netty-transport-raknet path: transport-raknet/build/libs/*.jar + if-no-files-found: error + - name: Archive Artifacts (transport-nethernet) + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 + with: + name: netty-transport-nethernet + path: transport-nethernet/build/libs/*.jar if-no-files-found: error \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 716d963..7cae5d8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -112,6 +112,7 @@ subprojects { nmcp { publishAggregation { project(":transport-raknet") + // TODO: Add publishing for transport-nethetnet username.set(System.getenv("MAVEN_CENTRAL_USERNAME") ?: "username") password.set(System.getenv("MAVEN_CENTRAL_PASSWORD") ?: "password") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f136bf5..d0df90f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] netty = "4.1.101.Final" junit = "5.14.1" +webrtc-java = "0.14.0" [libraries] @@ -9,6 +10,7 @@ netty-buffer = { group = "io.netty", name = "netty-buffer", version.ref = "netty netty-codec = { group = "io.netty", name = "netty-codec", version.ref = "netty" } netty-transport = { group = "io.netty", name = "netty-transport", version.ref = "netty" } netty-transport-native-unix-common = { group = "io.netty", name = "netty-transport-native-unix-common", version.ref = "netty" } +webrtc-java = { group = "dev.onvoid.webrtc", name = "webrtc-java", version.ref = "webrtc-java" } expiringmap = { group = "net.jodah", name = "expiringmap", version = "0.5.10" } diff --git a/settings.gradle.kts b/settings.gradle.kts index e6ad9fc..61bab7c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,7 +17,8 @@ rootProject.name = "network" plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "0.4.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } include("transport-raknet") +include("transport-nethernet") \ No newline at end of file diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts new file mode 100644 index 0000000..76a5d80 --- /dev/null +++ b/transport-nethernet/build.gradle.kts @@ -0,0 +1,34 @@ +description = "NetherNet transport for Netty" + +val nativePlatforms = listOf( + "windows-x86_64", + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64" +) + +dependencies { + api(libs.bundles.netty) + api(libs.expiringmap) + api(libs.webrtc.java) + nativePlatforms.forEach { platform -> + implementation(libs.webrtc.java) { + artifact { + classifier = platform + } + } + } + + testImplementation(libs.bundles.junit) + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.jar { + manifest.attributes["Automatic-Module-Name"] = "dev.kastle.netty.transport.nethernet" +} + +tasks.register("runDiscovery") { + mainClass.set("dev.kastle.netty.channel.nethernet.NetherNetScanner") + classpath = sourceSets["main"].runtimeClasspath +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java new file mode 100644 index 0000000..7b9e445 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -0,0 +1,274 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; +import dev.onvoid.webrtc.RTCDataChannel; +import dev.onvoid.webrtc.RTCDataChannelBuffer; +import dev.onvoid.webrtc.RTCDataChannelObserver; +import dev.onvoid.webrtc.RTCDataChannelState; +import dev.onvoid.webrtc.RTCPeerConnection; +import io.netty.buffer.ByteBuf; +import io.netty.channel.AbstractChannel; +import io.netty.channel.Channel; +import io.netty.channel.ChannelConfig; +import io.netty.channel.ChannelMetadata; +import io.netty.channel.ChannelOutboundBuffer; +import io.netty.channel.EventLoop; +import io.netty.util.ReferenceCountUtil; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.ByteBuffer; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public abstract class NetherNetChannel extends AbstractChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetChannel.class); + protected static final ChannelMetadata METADATA = new ChannelMetadata(false); + + protected final NetherNetChannelConfig config; + protected volatile RTCPeerConnection peerConnection; + protected volatile InetSocketAddress remoteAddress; + protected volatile InetSocketAddress localAddress; + + protected RTCDataChannel reliableChannel; + protected RTCDataChannel unreliableChannel; + + protected final Queue pendingWrites = new ConcurrentLinkedQueue<>(); + + protected volatile boolean open = true; + + protected NetherNetChannel(Channel parent, InetSocketAddress remote, InetSocketAddress local) { + super(parent); + this.remoteAddress = remote; + this.localAddress = local; + this.config = new NetherNetChannelConfig(this); + } + + public void setDataChannels(RTCDataChannel reliable, RTCDataChannel unreliable) { + this.reliableChannel = reliable; + this.unreliableChannel = unreliable; + + RTCDataChannelObserver observer = new RTCDataChannelObserver() { + private final ByteBuf assemblyBuf = config.getAllocator().buffer(); + private int currentSegmentCount = -1; + + @Override + public void onBufferedAmountChange(long previousAmount) { + } + + @Override + public void onStateChange() { + eventLoop().execute(() -> onDataChannelStateChange()); + } + + @Override + public void onMessage(RTCDataChannelBuffer buffer) { + ByteBuffer data = buffer.data; + if (!data.hasRemaining()) + return; + + int segments = data.get() & 0xFF; + + if (currentSegmentCount == -1) { + currentSegmentCount = segments; + } else { + if (segments != currentSegmentCount - 1) { + assemblyBuf.clear(); + currentSegmentCount = -1; + return; + } + currentSegmentCount = segments; + } + + if (data.hasRemaining()) { + byte[] payload = new byte[data.remaining()]; + data.get(payload); + assemblyBuf.writeBytes(payload); + } + + if (segments == 0) { + try { + if (assemblyBuf.isReadable()) { + ByteBuf packet = assemblyBuf.copy(); + assemblyBuf.skipBytes(assemblyBuf.readableBytes()); + + eventLoop().execute(() -> { + pipeline().fireChannelRead(packet); + pipeline().fireChannelReadComplete(); + }); + } + } catch (Exception e) { + log.error("Error processing packet", e); + } finally { + assemblyBuf.clear(); + currentSegmentCount = -1; + } + } + } + }; + + this.reliableChannel.registerObserver(observer); + + if (reliableChannel.getState() == RTCDataChannelState.OPEN) { + eventLoop().execute(this::onDataChannelStateChange); + } + } + + private void onDataChannelStateChange() { + if (isActive()) { + if (!pendingWrites.isEmpty()) { + pipeline().fireChannelWritabilityChanged(); + unsafe().flush(); + } + } else if (reliableChannel.getState() == RTCDataChannelState.CLOSED) { + close(); + } + } + + @Override + protected void doWrite(ChannelOutboundBuffer in) throws Exception { + if (!isActive()) { + Object msg; + while ((msg = in.current()) != null) { + ReferenceCountUtil.retain(msg); + pendingWrites.add(msg); + in.remove(); + } + return; + } + + while (!pendingWrites.isEmpty()) { + Object msg = pendingWrites.poll(); + try { + writeInternal(msg); + } finally { + ReferenceCountUtil.release(msg); + } + } + + Object msg; + while ((msg = in.current()) != null) { + writeInternal(msg); + in.remove(); + } + } + + private void writeInternal(Object msg) { + if (!(msg instanceof ByteBuf)) + return; + + ByteBuf payload = (ByteBuf) msg; + + ByteBuf framed = payload.retainedDuplicate(); + + int totalLength = framed.readableBytes(); + int maxPayload = NetherNetConstants.MAX_SCTP_MESSAGE_SIZE - 1; + + int segments = (totalLength / maxPayload); + if (totalLength % maxPayload != 0) + segments++; + + try { + int offset = 0; + for (int i = 0; i < segments; i++) { + int remaining = segments - 1 - i; + int chunkSize = Math.min(maxPayload, framed.readableBytes() - offset); + + ByteBuffer chunk = ByteBuffer.allocateDirect(1 + chunkSize); + chunk.put((byte) remaining); + + framed.getBytes(offset, chunk); + chunk.position(chunk.limit()); + chunk.flip(); + + reliableChannel.send(new RTCDataChannelBuffer(chunk, true)); + offset += chunkSize; + } + } catch (Exception e) { + pipeline().fireExceptionCaught(e); + } finally { + framed.release(); + } + } + + @Override + protected void doRegister() throws Exception { + } + + @Override + protected void doDeregister() throws Exception { + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + throw new UnsupportedOperationException("NetherNetChannel cannot be bound directly"); + } + + @Override + protected void doDisconnect() throws Exception { + doClose(); + } + + @Override + protected void doClose() throws Exception { + this.open = false; + + if (reliableChannel != null) { + reliableChannel.unregisterObserver(); + reliableChannel.close(); + } + if (unreliableChannel != null) { + unreliableChannel.unregisterObserver(); + unreliableChannel.close(); + } + if (peerConnection != null) { + peerConnection.close(); + } + + Object msg; + while ((msg = pendingWrites.poll()) != null) { + ReferenceCountUtil.release(msg); + } + } + + @Override + protected void doBeginRead() throws Exception { + } + + @Override + protected boolean isCompatible(EventLoop loop) { + return true; + } + + @Override + protected SocketAddress localAddress0() { + return this.localAddress; + } + + @Override + protected SocketAddress remoteAddress0() { + return this.remoteAddress; + } + + @Override + public ChannelConfig config() { + return this.config; + } + + @Override + public boolean isOpen() { + return this.open; + } + + @Override + public boolean isActive() { + return isOpen() && this.reliableChannel != null && this.reliableChannel.getState() == RTCDataChannelState.OPEN; + } + + @Override + public ChannelMetadata metadata() { + return METADATA; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java new file mode 100644 index 0000000..69025b9 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -0,0 +1,32 @@ +package dev.kastle.netty.channel.nethernet; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelFactory; +import io.netty.channel.socket.DatagramChannel; + +import java.lang.reflect.InvocationTargetException; + +public class NetherNetChannelFactory implements ChannelFactory { + private final Class channelClass; + + public NetherNetChannelFactory(Class channelClass) { + this.channelClass = channelClass; + } + + @Override + public T newChannel() { + try { + return channelClass.getDeclaredConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new RuntimeException("Failed to create channel", e); + } + } + + public static ChannelFactory server(Class clazz) { + return new NetherNetChannelFactory<>(NetherNetServerChannel.class); + } + + public static ChannelFactory client(Class clazz) { + return new NetherNetChannelFactory<>(NetherNetClientChannel.class); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java new file mode 100644 index 0000000..3880c8e --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java @@ -0,0 +1,30 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.onvoid.webrtc.RTCPeerConnection; +import io.netty.channel.Channel; +import io.netty.channel.ChannelPromise; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; + +public class NetherNetChildChannel extends NetherNetChannel { + public NetherNetChildChannel(Channel parent, RTCPeerConnection peerConnection, InetSocketAddress remote, InetSocketAddress local) { + super(parent, remote, local); + this.peerConnection = peerConnection; + } + + @Override + protected AbstractUnsafe newUnsafe() { + return new AbstractUnsafe() { + @Override + public void connect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) { + promise.setFailure(new UnsupportedOperationException("Child channel cannot connect")); + } + }; + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + throw new UnsupportedOperationException("Child channel cannot be bound"); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java new file mode 100644 index 0000000..2945807 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -0,0 +1,283 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery; +import dev.onvoid.webrtc.CreateSessionDescriptionObserver; +import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.onvoid.webrtc.PeerConnectionObserver; +import dev.onvoid.webrtc.PortAllocatorConfig; +import dev.onvoid.webrtc.RTCBundlePolicy; +import dev.onvoid.webrtc.RTCConfiguration; +import dev.onvoid.webrtc.RTCDataChannel; +import dev.onvoid.webrtc.RTCDataChannelInit; +import dev.onvoid.webrtc.RTCDataChannelState; +import dev.onvoid.webrtc.RTCIceCandidate; +import dev.onvoid.webrtc.RTCOfferOptions; +import dev.onvoid.webrtc.RTCPeerConnectionState; +import dev.onvoid.webrtc.RTCSdpType; +import dev.onvoid.webrtc.RTCSessionDescription; +import dev.onvoid.webrtc.SetSessionDescriptionObserver; +import dev.onvoid.webrtc.media.audio.HeadlessAudioDeviceModule; +import io.netty.channel.ChannelPromise; +import io.netty.util.ReferenceCountUtil; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +public class NetherNetClientChannel extends NetherNetChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetClientChannel.class); + + private HeadlessAudioDeviceModule audioDeviceModule; + private PeerConnectionFactory factory; + + private final NetherNetDiscovery discovery; + private final long networkId; + private volatile long connectionId; + + private volatile boolean handshakeComplete = false; + + private static final int HANDSHAKE_TIMEOUT_MS = 500; + private volatile ScheduledFuture handshakeTimeoutTask; + + public NetherNetClientChannel() { + super(null, null, null); + this.networkId = ThreadLocalRandom.current().nextLong(); + this.connectionId = ThreadLocalRandom.current().nextLong(); + this.discovery = new NetherNetDiscovery(this.networkId); + } + + public NetherNetClientChannel(long networkId) { + super(null, null, null); + this.networkId = networkId; + this.connectionId = ThreadLocalRandom.current().nextLong(); + this.discovery = new NetherNetDiscovery(this.networkId); + } + + @Override + public boolean isActive() { + return super.isActive() && handshakeComplete; + } + + @Override + protected void doClose() throws Exception { + super.doClose(); + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); + } + if (discovery != null) discovery.close(); + if (factory != null) factory.dispose(); + if (audioDeviceModule != null) audioDeviceModule.dispose(); + } + + @Override + protected AbstractUnsafe newUnsafe() { + return new NetherNetClientUnsafe(); + } + + private void resetAndRetryHandshake() { + if (!isOpen()) return; + + log.debug("Handshake timed out/failed. Resetting ID and retrying..."); + + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); + handshakeTimeoutTask = null; + } + + if (peerConnection != null) { + peerConnection.close(); + peerConnection = null; + } + + if (discovery != null) { + discovery.unregisterSignalHandler(this.connectionId); + } + + this.connectionId = ThreadLocalRandom.current().nextLong(); + + log.debug("Retrying connection with new Connection ID: {}", this.connectionId); + eventLoop().execute(() -> startHandshake(this.remoteAddress)); + } + + private class NetherNetClientUnsafe extends AbstractUnsafe { + @Override + public void connect(SocketAddress remote, SocketAddress local, ChannelPromise promise) { + if (!promise.setUncancellable() || !ensureOpen(promise)) { + return; + } + + if (!(remote instanceof InetSocketAddress)) { + promise.setFailure(new IllegalArgumentException("Unsupported address type")); + return; + } + + if (local instanceof InetSocketAddress) { + NetherNetClientChannel.this.localAddress = (InetSocketAddress) local; + } + + InetSocketAddress remoteAddress = (InetSocketAddress) remote; + NetherNetClientChannel.this.remoteAddress = remoteAddress; + + promise.setSuccess(); + + eventLoop().execute(() -> startHandshake(remoteAddress)); + } + } + + private void startHandshake(InetSocketAddress remoteAddress) { + try { + log.debug("Initializing WebRTC native components..."); + if (this.audioDeviceModule == null) { + this.audioDeviceModule = new HeadlessAudioDeviceModule(); + } + if (this.factory == null) { + this.factory = new PeerConnectionFactory(this.audioDeviceModule); + } + + if (!discovery.isActive()) { + log.debug("Binding discovery socket..."); + if (this.localAddress != null) { + discovery.bind(new InetSocketAddress(this.localAddress.getAddress(), 0)); + } else { + discovery.bind(0); + } + } else { + log.debug("Discovery socket already active. Reusing existing transport."); + } + + log.debug("Creating RTCPeerConnection..."); + RTCConfiguration rtcConfig = new RTCConfiguration(); + rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; + + PortAllocatorConfig allocatorConfig = new PortAllocatorConfig(); + allocatorConfig.setDisableAdapterEnumeration(true); + allocatorConfig.setDisableTcp(true); + rtcConfig.portAllocatorConfig = allocatorConfig; + + peerConnection = factory.createPeerConnection(rtcConfig, new PeerConnectionObserver() { + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + discovery.sendSignal(remoteAddress, 0, + NetherNetConstants.SIGNAL_CANDIDATE_ADD + " " + connectionId + " " + candidate.sdp); + } + + @Override + public void onConnectionChange(RTCPeerConnectionState state) { + log.debug("WebRTC Connection State: {}", state); + if (state == RTCPeerConnectionState.FAILED) { + close(); + } + } + + @Override public void onDataChannel(RTCDataChannel dataChannel) { } + }); + + log.debug("Creating Data Channels..."); + RTCDataChannelInit reliableInit = new RTCDataChannelInit(); + reliableInit.ordered = true; + reliableInit.protocol = NetherNetConstants.RELIABLE_CHANNEL_LABEL; + + RTCDataChannelInit unreliableInit = new RTCDataChannelInit(); + unreliableInit.ordered = false; + unreliableInit.maxRetransmits = 0; + + RTCDataChannel reliable = peerConnection.createDataChannel(NetherNetConstants.RELIABLE_CHANNEL_LABEL, reliableInit); + RTCDataChannel unreliable = peerConnection.createDataChannel(NetherNetConstants.UNRELIABLE_CHANNEL_LABEL, unreliableInit); + + setDataChannels(reliable, unreliable); + + discovery.registerSignalHandler(connectionId, (signal) -> { + String[] parts = signal.split(" ", 3); + if (parts.length < 3) return; + String type = parts[0]; + String data = parts[2]; + + eventLoop().execute(() -> { + switch (type) { + case NetherNetConstants.SIGNAL_CONNECT_RESPONSE: + log.debug("Received CONNECT_RESPONSE (Answer)"); + peerConnection.setRemoteDescription( + new RTCSessionDescription(RTCSdpType.ANSWER, data), + new SetSessionDescriptionObserver() { + @Override public void onSuccess() {} + @Override public void onFailure(String error) { + log.error("RemoteDesc error: {}", error); + close(); + } + } + ); + break; + case NetherNetConstants.SIGNAL_CANDIDATE_ADD: + peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); + break; + case NetherNetConstants.SIGNAL_CONNECT_ERROR: + log.error("Received CONNECT_ERROR from server: {}", data); + close(); + break; + } + }); + }); + + log.debug("Creating Offer..."); + peerConnection.createOffer(new RTCOfferOptions(), new CreateSessionDescriptionObserver() { + @Override + public void onSuccess(RTCSessionDescription description) { + peerConnection.setLocalDescription(description, new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + performDiscoveryAndConnect(remoteAddress, description.sdp); + } + @Override public void onFailure(String error) { + log.error("LocalDesc error: {}", error); + close(); + } + }); + } + @Override public void onFailure(String error) { + log.error("CreateOffer error: {}", error); + close(); + } + }); + + } catch (Exception e) { + log.error("Handshake initialization failed", e); + close(); + } + } + + private void performDiscoveryAndConnect(InetSocketAddress remote, String offerSdp) { + log.debug("Sending Discovery Request to {}", remote); + + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + handshakeTimeoutTask = eventLoop().schedule(() -> { + if (!handshakeComplete) { + resetAndRetryHandshake(); + } + }, HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + discovery.sendDiscoveryRequest(remote, (serverNetworkId, payload) -> { + try { + log.debug("Found Server NetworkID: {}", serverNetworkId); + discovery.sendSignal( + remote, + serverNetworkId, + NetherNetConstants.SIGNAL_CONNECT_REQUEST + " " + connectionId + " " + offerSdp + ); + } finally { + ReferenceCountUtil.release(payload); + } + }); + + eventLoop().scheduleAtFixedRate(() -> { + if (!handshakeComplete && reliableChannel != null && reliableChannel.getState() == RTCDataChannelState.OPEN) { + log.debug("NetherNet Connection Fully Established."); + handshakeComplete = true; + pipeline().fireChannelActive(); + } + }, 100, 100, TimeUnit.MILLISECONDS); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java new file mode 100644 index 0000000..67658cb --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -0,0 +1,114 @@ +package dev.kastle.netty.channel.nethernet; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import javax.crypto.Cipher; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.MessageDigest; + +public class NetherNetConstants { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetConstants.class); + + public static final int DISCOVERY_PORT = 7551; + public static final long APPLICATION_ID = 0xDEADBEEFL; + + // Packet IDs + public static final int ID_DISCOVERY_REQUEST = 0x00; + public static final int ID_DISCOVERY_RESPONSE = 0x01; + public static final int ID_DISCOVERY_MESSAGE = 0x02; + + // Signaling Message Types + public static final String SIGNAL_CONNECT_REQUEST = "CONNECTREQUEST"; + public static final String SIGNAL_CONNECT_RESPONSE = "CONNECTRESPONSE"; + public static final String SIGNAL_CANDIDATE_ADD = "CANDIDATEADD"; + public static final String SIGNAL_CONNECT_ERROR = "CONNECTERROR"; + + // SCTP Constants + public static final int MAX_SCTP_MESSAGE_SIZE = 10000; + public static final String RELIABLE_CHANNEL_LABEL = "ReliableDataChannel"; + public static final String UNRELIABLE_CHANNEL_LABEL = "UnreliableDataChannel"; + + private static final byte[] KEY_BYTES; + + static { + try { + ByteBuf buf = Unpooled.buffer(8); + buf.writeLongLE(APPLICATION_ID); + byte[] input = new byte[8]; + buf.readBytes(input); + buf.release(); + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + KEY_BYTES = digest.digest(input); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static byte[] encryptDiscoveryPacket(ByteBuf packet) throws Exception { + int len = packet.readableBytes(); + ByteBuf payload = Unpooled.buffer(2 + len); + payload.writeShortLE(len); + payload.writeBytes(packet); + + byte[] payloadBytes = new byte[payload.readableBytes()]; + payload.readBytes(payloadBytes); + payload.release(); + + SecretKeySpec secretKey = new SecretKeySpec(KEY_BYTES, "AES"); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + byte[] encrypted = cipher.doFinal(payloadBytes); + + Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); + SecretKeySpec secret_key = new SecretKeySpec(KEY_BYTES, "HmacSHA256"); + sha256_HMAC.init(secret_key); + byte[] signature = sha256_HMAC.doFinal(payloadBytes); + + ByteBuf result = Unpooled.buffer(signature.length + encrypted.length); + result.writeBytes(signature); + result.writeBytes(encrypted); + + byte[] out = new byte[result.readableBytes()]; + result.readBytes(out); + result.release(); + return out; + } + + public static ByteBuf decryptDiscoveryPacket(ByteBuf input) throws Exception { + if (input.readableBytes() < 32) { + log.debug("Discovery packet too short to contain valid signature"); + return null; + }; + + byte[] signature = new byte[32]; + input.readBytes(signature); + + byte[] encrypted = new byte[input.readableBytes()]; + input.readBytes(encrypted); + + SecretKeySpec secretKey = new SecretKeySpec(KEY_BYTES, "AES"); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + byte[] payloadBytes = cipher.doFinal(encrypted); + + Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); + SecretKeySpec secret_key = new SecretKeySpec(KEY_BYTES, "HmacSHA256"); + sha256_HMAC.init(secret_key); + byte[] calculatedSignature = sha256_HMAC.doFinal(payloadBytes); + + if (!MessageDigest.isEqual(signature, calculatedSignature)) { + log.debug("Invalid discovery packet signature"); + return null; + } + + ByteBuf payload = Unpooled.wrappedBuffer(payloadBytes); + payload.readUnsignedShortLE(); // Length prefix + + return payload; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java new file mode 100644 index 0000000..b5fadb7 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -0,0 +1,185 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; +import dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery; +import dev.onvoid.webrtc.CreateSessionDescriptionObserver; +import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.onvoid.webrtc.PeerConnectionObserver; +import dev.onvoid.webrtc.RTCAnswerOptions; +import dev.onvoid.webrtc.RTCBundlePolicy; +import dev.onvoid.webrtc.RTCConfiguration; +import dev.onvoid.webrtc.RTCDataChannel; +import dev.onvoid.webrtc.RTCIceCandidate; +import dev.onvoid.webrtc.RTCPeerConnection; +import dev.onvoid.webrtc.RTCPeerConnectionState; +import dev.onvoid.webrtc.RTCSdpType; +import dev.onvoid.webrtc.RTCSessionDescription; +import dev.onvoid.webrtc.SetSessionDescriptionObserver; +import dev.onvoid.webrtc.media.audio.HeadlessAudioDeviceModule; +import io.netty.channel.AbstractServerChannel; +import io.netty.channel.ChannelConfig; +import io.netty.channel.ChannelMetadata; +import io.netty.channel.EventLoop; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.concurrent.ThreadLocalRandom; + +public class NetherNetServerChannel extends AbstractServerChannel { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetServerChannel.class); + private static final ChannelMetadata METADATA = new ChannelMetadata(false, 16); + + private final NetherNetChannelConfig config = new NetherNetChannelConfig(this); + private final HeadlessAudioDeviceModule audioDeviceModule; + private final PeerConnectionFactory factory; + + private NetherNetDiscovery discovery; + private InetSocketAddress localAddress; + private long networkId; + + public NetherNetServerChannel() { + this.audioDeviceModule = new HeadlessAudioDeviceModule(); + this.factory = new PeerConnectionFactory(this.audioDeviceModule); + this.networkId = ThreadLocalRandom.current().nextLong(); + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + if (!(localAddress instanceof InetSocketAddress)) throw new IllegalArgumentException("Unsupported address type"); + this.localAddress = (InetSocketAddress) localAddress; + + this.discovery = new NetherNetDiscovery(this.networkId); + + this.discovery.setNewConnectionHandler((connectionId, offerSdp) -> { + // TODO: extract the sender's network ID from the packet context + acceptConnection(connectionId, offerSdp, "0"); + }); + + this.discovery.bind(); + + // TODO: Make configurable + this.discovery.setPongData("NetherNet Server", "World", 1, 0, 10); + } + + public void acceptConnection(long connectionId, String offerSdp, String remoteNetworkId) { + RTCConfiguration rtcConfig = new RTCConfiguration(); + rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; + + RTCPeerConnection pc = factory.createPeerConnection(rtcConfig, new PeerConnectionObserver() { + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + String candidateString = candidate.sdp; + discovery.sendSignal(localAddress, Long.parseLong(remoteNetworkId), + NetherNetConstants.SIGNAL_CANDIDATE_ADD + " " + connectionId + " " + candidateString); + } + + @Override + public void onConnectionChange(RTCPeerConnectionState state) { + log.info("Connection {} state changed: {}", connectionId, state); + if (state == RTCPeerConnectionState.FAILED || state == RTCPeerConnectionState.CLOSED) { + discovery.unregisterSignalHandler(connectionId); + } + } + + @Override + public void onDataChannel(RTCDataChannel dataChannel) { + // Server accepts channels created by client (handled in NetherNetChannel) + } + }); + + NetherNetChildChannel child = new NetherNetChildChannel(this, pc, new InetSocketAddress(0), localAddress); + + // Register Signal Handler + discovery.registerSignalHandler(connectionId, (signal) -> { + String[] parts = signal.split(" ", 3); + if (parts.length < 3) return; + + String type = parts[0]; + String data = parts[2]; + + switch (type) { + case NetherNetConstants.SIGNAL_CANDIDATE_ADD: + // Hardcode sdpMid to "0" and sdpMLineIndex to 0 based on NetherNet spec + RTCIceCandidate candidate = new RTCIceCandidate("0", 0, data); + pc.addIceCandidate(candidate); + break; + case NetherNetConstants.SIGNAL_CONNECT_ERROR: + log.error("Connection {} received error: {}", connectionId, data); + child.close(); + break; + } + }); + + // Handle Offer + pc.setRemoteDescription(new RTCSessionDescription(RTCSdpType.OFFER, offerSdp), new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + pc.createAnswer(new RTCAnswerOptions(), new CreateSessionDescriptionObserver() { + @Override + public void onSuccess(RTCSessionDescription description) { + pc.setLocalDescription(description, new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + discovery.sendSignal(localAddress, Long.parseLong(remoteNetworkId), + NetherNetConstants.SIGNAL_CONNECT_RESPONSE + " " + connectionId + " " + description.sdp); + + pipeline().fireChannelRead(child); + } + @Override public void onFailure(String error) { + log.error("Failed to set local description: {}", error); + } + }); + } + @Override public void onFailure(String error) { + log.error("Failed to create answer: {}", error); + } + }); + } + @Override public void onFailure(String error) { + log.error("Failed to set remote description (Offer): {}", error); + } + }); + } + + @Override + protected void doClose() throws Exception { + if (discovery != null) discovery.close(); + factory.dispose(); + audioDeviceModule.dispose(); + } + + @Override + protected void doBeginRead() throws Exception { + // Server channel doesn't read data directly + } + + @Override + protected SocketAddress localAddress0() { + return this.localAddress; + } + + @Override + protected boolean isCompatible(EventLoop loop) { + return true; + } + + @Override + public ChannelConfig config() { return config; } + + @Override + public boolean isOpen() { + return discovery != null; + } + + @Override + public boolean isActive() { + return isOpen(); + } + + @Override + public ChannelMetadata metadata() { + return METADATA; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetChannelConfig.java new file mode 100644 index 0000000..202c8ab --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetChannelConfig.java @@ -0,0 +1,34 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; +import io.netty.channel.DefaultChannelConfig; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class NetherNetChannelConfig extends DefaultChannelConfig { + private final Map, Object> options = new ConcurrentHashMap<>(); + + public NetherNetChannelConfig(Channel channel) { + super(channel); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + if (options.containsKey(option)) { + return (T) options.get(option); + } + return super.getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + if (super.setOption(option, value)) { + return true; + } + options.put(option, value); + return true; + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java new file mode 100644 index 0000000..ae2ea09 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet.config; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java new file mode 100644 index 0000000..00aa908 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java new file mode 100644 index 0000000..caba4da --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java @@ -0,0 +1,271 @@ +package dev.kastle.netty.handler.codec.nethernet; + +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.DatagramPacket; +import io.netty.channel.socket.nio.NioDatagramChannel; +import io.netty.util.concurrent.ScheduledFuture; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public class NetherNetDiscovery extends SimpleChannelInboundHandler { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscovery.class); + + private final long networkId; + private final Map> signalHandlers = new ConcurrentHashMap<>(); + private Channel channel; + private byte[] pongData; + private BiConsumer newConnectionHandler; + private BiConsumer discoveryCallback; + + public NetherNetDiscovery(long networkId) { + this.networkId = networkId; + } + + public void bind() { + bind(NetherNetConstants.DISCOVERY_PORT); + } + + public void bind(int port) { + EventLoopGroup group = new NioEventLoopGroup(1); + try { + Bootstrap bootstrap = new Bootstrap(); + bootstrap.group(group) + .channel(NioDatagramChannel.class) + .option(ChannelOption.SO_BROADCAST, true) + .handler(this); + + this.channel = bootstrap.bind(port).sync().channel(); + log.info("NetherNet Discovery listening on port {}", port); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public void bind(InetSocketAddress address) { + EventLoopGroup group = new NioEventLoopGroup(1); + try { + Bootstrap bootstrap = new Bootstrap(); + bootstrap.group(group) + .channel(NioDatagramChannel.class) + .option(ChannelOption.SO_BROADCAST, true) + .handler(this); + + this.channel = bootstrap.bind(address).sync().channel(); + log.info("NetherNet Discovery listening on {}", address); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public void sendDiscoveryRequest(InetSocketAddress target, BiConsumer onServerFound) { + this.discoveryCallback = onServerFound; + + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_REQUEST); + buf.writeLongLE(this.networkId); + buf.writeZero(8); // Padding + + sendPacket(buf, target); + } + + public void setPongData(String serverName, String levelName, int gameType, int playerCount, int maxPlayerCount) { + ByteBuf buf = Unpooled.buffer(); + buf.writeByte(4); // Version + writeString(buf, serverName); + writeString(buf, levelName); + buf.writeByte(gameType << 1); // Encoded as value << 1 + buf.writeIntLE(playerCount); + buf.writeIntLE(maxPlayerCount); + buf.writeBoolean(false); // isEditorWorld + buf.writeBoolean(false); // Hardcore + buf.writeZero(2); // Unknown + + byte[] binaryData = new byte[buf.readableBytes()]; + buf.readBytes(binaryData); + buf.release(); + + String hex = ByteBufUtil.hexDump(binaryData); + byte[] hexBytes = hex.getBytes(StandardCharsets.UTF_8); + + ByteBuf response = Unpooled.buffer(); + response.writeIntLE(hexBytes.length); + response.writeBytes(hexBytes); + + this.pongData = new byte[response.readableBytes()]; + response.readBytes(this.pongData); + response.release(); + } + + public void registerSignalHandler(long connectionId, Consumer handler) { + this.signalHandlers.put(connectionId, handler); + } + + public void unregisterSignalHandler(long connectionId) { + this.signalHandlers.remove(connectionId); + } + + public void setNewConnectionHandler(BiConsumer handler) { + this.newConnectionHandler = handler; + } + + /** + * Sends a signal immediately and schedules it to be resent periodically + * until the returned ScheduledFuture is cancelled. + */ + public ScheduledFuture sendSignalRetrying(InetSocketAddress recipient, long targetNetworkId, String data, long delayMs) { + return channel.eventLoop().scheduleAtFixedRate(() -> { + log.debug("Resending signal to {}: {}", recipient, data); + sendSignal(recipient, targetNetworkId, data); + }, 0, delayMs, TimeUnit.MILLISECONDS); + } + + public void sendSignal(InetSocketAddress recipient, long targetNetworkId, String data) { + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_MESSAGE); + buf.writeLongLE(this.networkId); // Sender ID + buf.writeZero(8); // Padding + + buf.writeLongLE(targetNetworkId); // Recipient ID + byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8); + buf.writeIntLE(dataBytes.length); + buf.writeBytes(dataBytes); + + sendPacket(buf, recipient); + } + + private void sendPacket(ByteBuf packetData, InetSocketAddress target) { + try { + byte[] encrypted = NetherNetConstants.encryptDiscoveryPacket(packetData); + channel.writeAndFlush(new DatagramPacket(Unpooled.wrappedBuffer(encrypted), target)); + } catch (Exception e) { + log.error("Failed to encrypt discovery packet", e); + } finally { + packetData.release(); + } + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) throws Exception { + ByteBuf content = packet.content(); + ByteBuf decrypted = null; + try { + decrypted = NetherNetConstants.decryptDiscoveryPacket(content); + } catch (Exception e) { + log.debug("Failed to decrypt discovery packet from {}", packet.sender(), e); + return; + } + + if (decrypted == null) { + log.debug("Received invalid discovery packet from {}", packet.sender()); + return; + } + + try { + int packetId = decrypted.readUnsignedShortLE(); + long senderId = decrypted.readLongLE(); + decrypted.skipBytes(8); // Padding + + if (senderId == this.networkId) { + log.debug("Ignoring own discovery packet"); + return; + } + + switch (packetId) { + case NetherNetConstants.ID_DISCOVERY_REQUEST: + log.trace("Handled discovery request from {}", packet.sender()); + handleRequest(senderId, packet.sender()); + break; + case NetherNetConstants.ID_DISCOVERY_MESSAGE: + log.trace("Handled discovery message from {}", packet.sender()); + log.trace("Message Data: {}", decrypted.toString(StandardCharsets.UTF_8)); + handleMessage(decrypted, senderId); + break; + case NetherNetConstants.ID_DISCOVERY_RESPONSE: + log.trace("Handled discovery response from {}", packet.sender()); + if (discoveryCallback != null) { + log.trace("Response Data: {}", decrypted.toString(StandardCharsets.UTF_8)); + // Pass the payload (decrypted buffer) to the callback + // We retain it because we are passing it out of the pipeline handler + discoveryCallback.accept(senderId, decrypted.retain()); + } + break; + } + } catch (Exception e) { + log.debug("Error processing discovery packet from {}", packet.sender(), e); + } finally { + decrypted.release(); + } + } + + private void handleRequest(long senderId, InetSocketAddress sender) { + if (this.pongData == null) return; + + ByteBuf buf = Unpooled.buffer(); + buf.writeShortLE(NetherNetConstants.ID_DISCOVERY_RESPONSE); + buf.writeLongLE(this.networkId); + buf.writeZero(8); + buf.writeBytes(this.pongData); + + sendPacket(buf, sender); + } + + private void handleMessage(ByteBuf data, long senderId) { + long recipientId = data.readLongLE(); + if (recipientId != this.networkId) return; + + int len = data.readIntLE(); + String messageData = data.readCharSequence(len, StandardCharsets.UTF_8).toString(); + + String[] parts = messageData.split(" ", 3); + if (parts.length < 2) return; + + try { + String type = parts[0]; + long connectionId = Long.parseUnsignedLong(parts[1]); + + Consumer handler = signalHandlers.get(connectionId); + if (handler != null) { + handler.accept(messageData); + } else if (NetherNetConstants.SIGNAL_CONNECT_REQUEST.equals(type) && newConnectionHandler != null) { + String payload = parts.length > 2 ? parts[2] : ""; + newConnectionHandler.accept(connectionId, payload); + } + } catch (NumberFormatException e) { + // Invalid format + } + } + + public void close() { + if (channel != null) { + channel.close(); + } + } + + public boolean isActive() { + return channel != null && channel.isActive(); + } + + private void writeString(ByteBuf buf, String s) { + byte[] b = s.getBytes(StandardCharsets.UTF_8); + buf.writeByte(b.length); + buf.writeBytes(b); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/package-info.java new file mode 100644 index 0000000..d3b9711 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.handler.codec.nethernet; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java new file mode 100644 index 0000000..388e204 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java @@ -0,0 +1,80 @@ +package dev.kastle.netty.util.nethernet; + +import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ThreadLocalRandom; + +/** + * A simple scanner example for discovering NetherNet servers on the local network. + */ +public class NetherNetScanner { + public static void main(String[] args) throws Exception { + long myNetworkId = ThreadLocalRandom.current().nextLong(); + dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery discovery = new NetherNetDiscovery(myNetworkId); + + discovery.bind(new InetSocketAddress("::", 0)); + + System.out.println("Scanning for NetherNet servers on port 7551..."); + + InetSocketAddress broadcastTarget = new InetSocketAddress("255.255.255.255", NetherNetConstants.DISCOVERY_PORT); + + discovery.sendDiscoveryRequest(broadcastTarget, (senderId, payload) -> { + try { + if (payload.readableBytes() < 4) return; + + int length = payload.readIntLE(); + if (payload.readableBytes() < length) return; + + String hexString = payload.readCharSequence(length, StandardCharsets.UTF_8).toString(); + + byte[] binaryData = ByteBufUtil.decodeHexDump(hexString); + ByteBuf data = Unpooled.wrappedBuffer(binaryData); + + try { + int version = data.readUnsignedByte(); + String serverName = readString(data); + String levelName = readString(data); + int gameType = data.readUnsignedByte() >> 1; + int playerCount = data.readIntLE(); + int maxPlayers = data.readIntLE(); + boolean isEditor = data.readBoolean(); + boolean isHardcore = data.readBoolean(); + + System.out.println("--------------------------------"); + System.out.println("Found Server: " + senderId); + System.out.println("MOTD: " + serverName); + System.out.println("Level: " + levelName); + System.out.println("Players: " + playerCount + "/" + maxPlayers); + System.out.println("Game Mode: " + gameType); + System.out.println("Editor World: " + isEditor); + System.out.println("Hardcore: " + isHardcore); + System.out.println("Version: " + version); + System.out.println("--------------------------------"); + + } finally { + data.release(); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + payload.release(); + } + }); + + Thread.sleep(10000); + discovery.close(); + } + + private static String readString(ByteBuf buf) { + if (!buf.isReadable()) return ""; + int len = buf.readUnsignedByte(); + if (buf.readableBytes() < len) return ""; + return buf.readCharSequence(len, StandardCharsets.UTF_8).toString(); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java new file mode 100644 index 0000000..b7355d6 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.util.nethernet; From ba15c3b7460fdfab0ccfa79513634de81e20d604 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 6 Dec 2025 14:48:23 -0800 Subject: [PATCH 02/22] Correct discovery packet length and properly encode discovery string length as varint Co-authored-by: RaphiMC <50594595+RaphiMC@users.noreply.github.com> Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../netty/channel/nethernet/NetherNetConstants.java | 4 ++-- .../handler/codec/nethernet/NetherNetDiscovery.java | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java index 67658cb..32692cf 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -50,8 +50,8 @@ public class NetherNetConstants { } public static byte[] encryptDiscoveryPacket(ByteBuf packet) throws Exception { - int len = packet.readableBytes(); - ByteBuf payload = Unpooled.buffer(2 + len); + int len = packet.readableBytes() + 2; + ByteBuf payload = Unpooled.buffer(len); payload.writeShortLE(len); payload.writeBytes(packet); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java index caba4da..aeb9ade 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java @@ -265,7 +265,15 @@ public boolean isActive() { private void writeString(ByteBuf buf, String s) { byte[] b = s.getBytes(StandardCharsets.UTF_8); - buf.writeByte(b.length); + this.writeUnsignedVarInt(buf, b.length); buf.writeBytes(b); } + + private void writeUnsignedVarInt(ByteBuf buf, int value) { + while ((value & 0xFFFFFF80) != 0) { + buf.writeByte((byte) ((value & 0x7F) | 0x80)); + value >>>= 7; + } + buf.writeByte((byte) value); + } } \ No newline at end of file From e5cd21a8452263e434e0a3ce11f3a75e830b0df4 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 6 Dec 2025 15:08:55 -0800 Subject: [PATCH 03/22] Refactor NetherNetClientChannel using RTCDataChannelObserver to succeed connectPromise Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../nethernet/NetherNetClientChannel.java | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index 2945807..4cd7ea6 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -8,7 +8,9 @@ import dev.onvoid.webrtc.RTCBundlePolicy; import dev.onvoid.webrtc.RTCConfiguration; import dev.onvoid.webrtc.RTCDataChannel; +import dev.onvoid.webrtc.RTCDataChannelBuffer; import dev.onvoid.webrtc.RTCDataChannelInit; +import dev.onvoid.webrtc.RTCDataChannelObserver; import dev.onvoid.webrtc.RTCDataChannelState; import dev.onvoid.webrtc.RTCIceCandidate; import dev.onvoid.webrtc.RTCOfferOptions; @@ -25,6 +27,7 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.nio.channels.ClosedChannelException; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; @@ -40,9 +43,11 @@ public class NetherNetClientChannel extends NetherNetChannel { private volatile boolean handshakeComplete = false; - private static final int HANDSHAKE_TIMEOUT_MS = 500; - private volatile ScheduledFuture handshakeTimeoutTask; + private ChannelPromise connectPromise; + private static final int HANDSHAKE_TIMEOUT_MS = 500; + private volatile ScheduledFuture handshakeTimeoutTask; + public NetherNetClientChannel() { super(null, null, null); this.networkId = ThreadLocalRandom.current().nextLong(); @@ -71,6 +76,10 @@ protected void doClose() throws Exception { if (discovery != null) discovery.close(); if (factory != null) factory.dispose(); if (audioDeviceModule != null) audioDeviceModule.dispose(); + + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ClosedChannelException()); + } } @Override @@ -122,7 +131,7 @@ public void connect(SocketAddress remote, SocketAddress local, ChannelPromise pr InetSocketAddress remoteAddress = (InetSocketAddress) remote; NetherNetClientChannel.this.remoteAddress = remoteAddress; - promise.setSuccess(); + NetherNetClientChannel.this.connectPromise = promise; eventLoop().execute(() -> startHandshake(remoteAddress)); } @@ -145,8 +154,6 @@ private void startHandshake(InetSocketAddress remoteAddress) { } else { discovery.bind(0); } - } else { - log.debug("Discovery socket already active. Reusing existing transport."); } log.debug("Creating RTCPeerConnection..."); @@ -187,8 +194,35 @@ public void onConnectionChange(RTCPeerConnectionState state) { RTCDataChannel reliable = peerConnection.createDataChannel(NetherNetConstants.RELIABLE_CHANNEL_LABEL, reliableInit); RTCDataChannel unreliable = peerConnection.createDataChannel(NetherNetConstants.UNRELIABLE_CHANNEL_LABEL, unreliableInit); - - setDataChannels(reliable, unreliable); + + reliable.registerObserver(new RTCDataChannelObserver() { + @Override + public void onStateChange() { + if (reliable.getState() == RTCDataChannelState.OPEN) { + // Switch back to Netty Thread to complete the connection safely + eventLoop().execute(() -> { + if (!handshakeComplete) { + log.debug("NetherNet Connection Fully Established (via Observer)."); + handshakeComplete = true; + + setDataChannels(reliable, unreliable); + + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.trySuccess(); + } + pipeline().fireChannelActive(); + } + }); + } + } + + @Override public void onBufferedAmountChange(long previousAmount) {} + @Override public void onMessage(RTCDataChannelBuffer buffer) { + // This shouldn't happen during handshake, but if it does, release the buffer to avoid leaks. + // Real data handling happens after setDataChannels swaps the observer. + ReferenceCountUtil.release(buffer); + } + }); discovery.registerSignalHandler(connectionId, (signal) -> { String[] parts = signal.split(" ", 3); @@ -270,14 +304,6 @@ private void performDiscoveryAndConnect(InetSocketAddress remote, String offerSd } finally { ReferenceCountUtil.release(payload); } - }); - - eventLoop().scheduleAtFixedRate(() -> { - if (!handshakeComplete && reliableChannel != null && reliableChannel.getState() == RTCDataChannelState.OPEN) { - log.debug("NetherNet Connection Fully Established."); - handshakeComplete = true; - pipeline().fireChannelActive(); - } - }, 100, 100, TimeUnit.MILLISECONDS); + }); } } \ No newline at end of file From 961a5c1f8bd64b52d87871c56010e6e664fc4b25 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:29:59 -0800 Subject: [PATCH 04/22] Encourage use of single PeerConnectionFactory Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../nethernet/NetherNetChannelFactory.java | 27 ++++++++++++------- .../nethernet/NetherNetClientChannel.java | 24 ++++++----------- .../nethernet/NetherNetServerChannel.java | 16 ++++++----- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java index 69025b9..4509dc0 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -1,32 +1,39 @@ package dev.kastle.netty.channel.nethernet; +import dev.onvoid.webrtc.PeerConnectionFactory; import io.netty.channel.Channel; import io.netty.channel.ChannelFactory; -import io.netty.channel.socket.DatagramChannel; +import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; public class NetherNetChannelFactory implements ChannelFactory { - private final Class channelClass; + private final PeerConnectionFactory peerConnectionFactory; + private final Constructor constructor; - public NetherNetChannelFactory(Class channelClass) { - this.channelClass = channelClass; + public NetherNetChannelFactory(Class channelClass, PeerConnectionFactory factory) { + this.peerConnectionFactory = factory; + try { + this.constructor = channelClass.getConstructor(PeerConnectionFactory.class); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("Channel class " + channelClass.getName() + " must have a public constructor accepting PeerConnectionFactory", e); + } } @Override public T newChannel() { try { - return channelClass.getDeclaredConstructor().newInstance(); - } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + return constructor.newInstance(peerConnectionFactory); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { throw new RuntimeException("Failed to create channel", e); } } - public static ChannelFactory server(Class clazz) { - return new NetherNetChannelFactory<>(NetherNetServerChannel.class); + public static ChannelFactory server(PeerConnectionFactory factory) { + return new NetherNetChannelFactory<>(NetherNetServerChannel.class, factory); } - public static ChannelFactory client(Class clazz) { - return new NetherNetChannelFactory<>(NetherNetClientChannel.class); + public static ChannelFactory client(PeerConnectionFactory factory) { + return new NetherNetChannelFactory<>(NetherNetClientChannel.class, factory); } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index 4cd7ea6..a999bc2 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -18,7 +18,6 @@ import dev.onvoid.webrtc.RTCSdpType; import dev.onvoid.webrtc.RTCSessionDescription; import dev.onvoid.webrtc.SetSessionDescriptionObserver; -import dev.onvoid.webrtc.media.audio.HeadlessAudioDeviceModule; import io.netty.channel.ChannelPromise; import io.netty.util.ReferenceCountUtil; import io.netty.util.concurrent.ScheduledFuture; @@ -34,8 +33,7 @@ public class NetherNetClientChannel extends NetherNetChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetClientChannel.class); - private HeadlessAudioDeviceModule audioDeviceModule; - private PeerConnectionFactory factory; + private final PeerConnectionFactory factory; private final NetherNetDiscovery discovery; private final long networkId; @@ -49,15 +47,17 @@ public class NetherNetClientChannel extends NetherNetChannel { private volatile ScheduledFuture handshakeTimeoutTask; public NetherNetClientChannel() { - super(null, null, null); - this.networkId = ThreadLocalRandom.current().nextLong(); - this.connectionId = ThreadLocalRandom.current().nextLong(); - this.discovery = new NetherNetDiscovery(this.networkId); + this(new PeerConnectionFactory()); } - public NetherNetClientChannel(long networkId) { + public NetherNetClientChannel(PeerConnectionFactory factory) { + this(ThreadLocalRandom.current().nextLong(), factory); + } + + public NetherNetClientChannel(long networkId, PeerConnectionFactory factory) { super(null, null, null); this.networkId = networkId; + this.factory = factory; this.connectionId = ThreadLocalRandom.current().nextLong(); this.discovery = new NetherNetDiscovery(this.networkId); } @@ -74,8 +74,6 @@ protected void doClose() throws Exception { handshakeTimeoutTask.cancel(false); } if (discovery != null) discovery.close(); - if (factory != null) factory.dispose(); - if (audioDeviceModule != null) audioDeviceModule.dispose(); if (connectPromise != null && !connectPromise.isDone()) { connectPromise.tryFailure(new ClosedChannelException()); @@ -140,12 +138,6 @@ public void connect(SocketAddress remote, SocketAddress local, ChannelPromise pr private void startHandshake(InetSocketAddress remoteAddress) { try { log.debug("Initializing WebRTC native components..."); - if (this.audioDeviceModule == null) { - this.audioDeviceModule = new HeadlessAudioDeviceModule(); - } - if (this.factory == null) { - this.factory = new PeerConnectionFactory(this.audioDeviceModule); - } if (!discovery.isActive()) { log.debug("Binding discovery socket..."); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index b5fadb7..d2a81fb 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -15,7 +15,6 @@ import dev.onvoid.webrtc.RTCSdpType; import dev.onvoid.webrtc.RTCSessionDescription; import dev.onvoid.webrtc.SetSessionDescriptionObserver; -import dev.onvoid.webrtc.media.audio.HeadlessAudioDeviceModule; import io.netty.channel.AbstractServerChannel; import io.netty.channel.ChannelConfig; import io.netty.channel.ChannelMetadata; @@ -32,7 +31,6 @@ public class NetherNetServerChannel extends AbstractServerChannel { private static final ChannelMetadata METADATA = new ChannelMetadata(false, 16); private final NetherNetChannelConfig config = new NetherNetChannelConfig(this); - private final HeadlessAudioDeviceModule audioDeviceModule; private final PeerConnectionFactory factory; private NetherNetDiscovery discovery; @@ -40,9 +38,16 @@ public class NetherNetServerChannel extends AbstractServerChannel { private long networkId; public NetherNetServerChannel() { - this.audioDeviceModule = new HeadlessAudioDeviceModule(); - this.factory = new PeerConnectionFactory(this.audioDeviceModule); - this.networkId = ThreadLocalRandom.current().nextLong(); + this(new PeerConnectionFactory()); + } + + public NetherNetServerChannel(PeerConnectionFactory factory) { + this(ThreadLocalRandom.current().nextLong(), factory); + } + + public NetherNetServerChannel(long networkId, PeerConnectionFactory factory) { + this.factory = factory; + this.networkId = networkId; } @Override @@ -147,7 +152,6 @@ public void onSuccess() { protected void doClose() throws Exception { if (discovery != null) discovery.close(); factory.dispose(); - audioDeviceModule.dispose(); } @Override From 1f4ded4399a95e64fc2f753c4b70850ca3c7c2d9 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 27 Dec 2025 23:36:32 -0800 Subject: [PATCH 05/22] Switch to data only webrtc lib Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- gradle/libs.versions.toml | 4 +-- transport-nethernet/build.gradle.kts | 9 +++++ .../channel/nethernet/NetherNetChannel.java | 10 +++--- .../nethernet/NetherNetChannelFactory.java | 2 +- .../nethernet/NetherNetChildChannel.java | 2 +- .../nethernet/NetherNetClientChannel.java | 34 +++++++++---------- .../nethernet/NetherNetServerChannel.java | 26 +++++++------- 7 files changed, 48 insertions(+), 39 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0df90f..065f4bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] netty = "4.1.101.Final" junit = "5.14.1" -webrtc-java = "0.14.0" +webrtc-java = "1.0.2" [libraries] @@ -10,7 +10,7 @@ netty-buffer = { group = "io.netty", name = "netty-buffer", version.ref = "netty netty-codec = { group = "io.netty", name = "netty-codec", version.ref = "netty" } netty-transport = { group = "io.netty", name = "netty-transport", version.ref = "netty" } netty-transport-native-unix-common = { group = "io.netty", name = "netty-transport-native-unix-common", version.ref = "netty" } -webrtc-java = { group = "dev.onvoid.webrtc", name = "webrtc-java", version.ref = "webrtc-java" } +webrtc-java = { group = "dev.kastle.webrtc", name = "webrtc-java", version.ref = "webrtc-java" } expiringmap = { group = "net.jodah", name = "expiringmap", version = "0.5.10" } diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts index 76a5d80..edf4601 100644 --- a/transport-nethernet/build.gradle.kts +++ b/transport-nethernet/build.gradle.kts @@ -2,6 +2,7 @@ description = "NetherNet transport for Netty" val nativePlatforms = listOf( "windows-x86_64", + "windows-aarch64", "linux-x86_64", "linux-aarch64", "macos-x86_64", @@ -24,6 +25,14 @@ dependencies { testRuntimeOnly(libs.junit.platform.launcher) } +configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + withJavadocJar() + withSourcesJar() +} + tasks.jar { manifest.attributes["Automatic-Module-Name"] = "dev.kastle.netty.transport.nethernet" } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java index 7b9e445..f88d687 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -1,11 +1,11 @@ package dev.kastle.netty.channel.nethernet; import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; -import dev.onvoid.webrtc.RTCDataChannel; -import dev.onvoid.webrtc.RTCDataChannelBuffer; -import dev.onvoid.webrtc.RTCDataChannelObserver; -import dev.onvoid.webrtc.RTCDataChannelState; -import dev.onvoid.webrtc.RTCPeerConnection; +import dev.kastle.webrtc.RTCDataChannel; +import dev.kastle.webrtc.RTCDataChannelBuffer; +import dev.kastle.webrtc.RTCDataChannelObserver; +import dev.kastle.webrtc.RTCDataChannelState; +import dev.kastle.webrtc.RTCPeerConnection; import io.netty.buffer.ByteBuf; import io.netty.channel.AbstractChannel; import io.netty.channel.Channel; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java index 4509dc0..a8577ac 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -1,6 +1,6 @@ package dev.kastle.netty.channel.nethernet; -import dev.onvoid.webrtc.PeerConnectionFactory; +import dev.kastle.webrtc.PeerConnectionFactory; import io.netty.channel.Channel; import io.netty.channel.ChannelFactory; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java index 3880c8e..758850d 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java @@ -1,6 +1,6 @@ package dev.kastle.netty.channel.nethernet; -import dev.onvoid.webrtc.RTCPeerConnection; +import dev.kastle.webrtc.RTCPeerConnection; import io.netty.channel.Channel; import io.netty.channel.ChannelPromise; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index a999bc2..480cb9b 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -1,23 +1,23 @@ package dev.kastle.netty.channel.nethernet; import dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery; -import dev.onvoid.webrtc.CreateSessionDescriptionObserver; -import dev.onvoid.webrtc.PeerConnectionFactory; -import dev.onvoid.webrtc.PeerConnectionObserver; -import dev.onvoid.webrtc.PortAllocatorConfig; -import dev.onvoid.webrtc.RTCBundlePolicy; -import dev.onvoid.webrtc.RTCConfiguration; -import dev.onvoid.webrtc.RTCDataChannel; -import dev.onvoid.webrtc.RTCDataChannelBuffer; -import dev.onvoid.webrtc.RTCDataChannelInit; -import dev.onvoid.webrtc.RTCDataChannelObserver; -import dev.onvoid.webrtc.RTCDataChannelState; -import dev.onvoid.webrtc.RTCIceCandidate; -import dev.onvoid.webrtc.RTCOfferOptions; -import dev.onvoid.webrtc.RTCPeerConnectionState; -import dev.onvoid.webrtc.RTCSdpType; -import dev.onvoid.webrtc.RTCSessionDescription; -import dev.onvoid.webrtc.SetSessionDescriptionObserver; +import dev.kastle.webrtc.CreateSessionDescriptionObserver; +import dev.kastle.webrtc.PeerConnectionFactory; +import dev.kastle.webrtc.PeerConnectionObserver; +import dev.kastle.webrtc.PortAllocatorConfig; +import dev.kastle.webrtc.RTCBundlePolicy; +import dev.kastle.webrtc.RTCConfiguration; +import dev.kastle.webrtc.RTCDataChannel; +import dev.kastle.webrtc.RTCDataChannelBuffer; +import dev.kastle.webrtc.RTCDataChannelInit; +import dev.kastle.webrtc.RTCDataChannelObserver; +import dev.kastle.webrtc.RTCDataChannelState; +import dev.kastle.webrtc.RTCIceCandidate; +import dev.kastle.webrtc.RTCOfferOptions; +import dev.kastle.webrtc.RTCPeerConnectionState; +import dev.kastle.webrtc.RTCSdpType; +import dev.kastle.webrtc.RTCSessionDescription; +import dev.kastle.webrtc.SetSessionDescriptionObserver; import io.netty.channel.ChannelPromise; import io.netty.util.ReferenceCountUtil; import io.netty.util.concurrent.ScheduledFuture; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index d2a81fb..edd2b2a 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -2,19 +2,19 @@ import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; import dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery; -import dev.onvoid.webrtc.CreateSessionDescriptionObserver; -import dev.onvoid.webrtc.PeerConnectionFactory; -import dev.onvoid.webrtc.PeerConnectionObserver; -import dev.onvoid.webrtc.RTCAnswerOptions; -import dev.onvoid.webrtc.RTCBundlePolicy; -import dev.onvoid.webrtc.RTCConfiguration; -import dev.onvoid.webrtc.RTCDataChannel; -import dev.onvoid.webrtc.RTCIceCandidate; -import dev.onvoid.webrtc.RTCPeerConnection; -import dev.onvoid.webrtc.RTCPeerConnectionState; -import dev.onvoid.webrtc.RTCSdpType; -import dev.onvoid.webrtc.RTCSessionDescription; -import dev.onvoid.webrtc.SetSessionDescriptionObserver; +import dev.kastle.webrtc.CreateSessionDescriptionObserver; +import dev.kastle.webrtc.PeerConnectionFactory; +import dev.kastle.webrtc.PeerConnectionObserver; +import dev.kastle.webrtc.RTCAnswerOptions; +import dev.kastle.webrtc.RTCBundlePolicy; +import dev.kastle.webrtc.RTCConfiguration; +import dev.kastle.webrtc.RTCDataChannel; +import dev.kastle.webrtc.RTCIceCandidate; +import dev.kastle.webrtc.RTCPeerConnection; +import dev.kastle.webrtc.RTCPeerConnectionState; +import dev.kastle.webrtc.RTCSdpType; +import dev.kastle.webrtc.RTCSessionDescription; +import dev.kastle.webrtc.SetSessionDescriptionObserver; import io.netty.channel.AbstractServerChannel; import io.netty.channel.ChannelConfig; import io.netty.channel.ChannelMetadata; From 7091257415bcebb3ff231929da21380945f3c2e2 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:51:29 -0800 Subject: [PATCH 06/22] Refactor to support joining realms Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- gradle/libs.versions.toml | 9 +- transport-nethernet/build.gradle.kts | 5 +- .../channel/nethernet/NetherNetChannel.java | 4 +- .../nethernet/NetherNetChannelFactory.java | 31 +- .../nethernet/NetherNetClientChannel.java | 414 +++++++++--------- .../nethernet/NetherNetServerChannel.java | 2 +- .../nethernet/config/NetherNetAddress.java | 33 ++ .../signaling}/NetherNetDiscovery.java | 2 +- .../NetherNetDiscoverySignaling.java | 127 ++++++ .../signaling/NetherNetSignaling.java | 39 ++ .../signaling/NetherNetXboxSignaling.java | 217 +++++++++ .../nethernet/signaling/package-info.java | 1 + .../handler/codec/nethernet/package-info.java | 1 - .../util/nethernet/NetherNetScanner.java | 4 +- 14 files changed, 657 insertions(+), 232 deletions(-) create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java rename transport-nethernet/src/main/java/dev/kastle/netty/{handler/codec/nethernet => channel/nethernet/signaling}/NetherNetDiscovery.java (99%) create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java delete mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/package-info.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 065f4bc..7be3bc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,20 @@ [versions] -netty = "4.1.101.Final" +netty = "4.1.130.Final" junit = "5.14.1" webrtc-java = "1.0.2" - +gson = "2.13.2" [libraries] +expiringmap = { group = "net.jodah", name = "expiringmap", version = "0.5.10" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } netty-common = { group = "io.netty", name = "netty-common", version.ref = "netty" } netty-buffer = { group = "io.netty", name = "netty-buffer", version.ref = "netty" } netty-codec = { group = "io.netty", name = "netty-codec", version.ref = "netty" } +netty-codec-http = { group = "io.netty", name = "netty-codec-http", version.ref = "netty" } netty-transport = { group = "io.netty", name = "netty-transport", version.ref = "netty" } netty-transport-native-unix-common = { group = "io.netty", name = "netty-transport-native-unix-common", version.ref = "netty" } webrtc-java = { group = "dev.kastle.webrtc", name = "webrtc-java", version.ref = "webrtc-java" } -expiringmap = { group = "net.jodah", name = "expiringmap", version = "0.5.10" } - # Test dependencies junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts index edf4601..6c34384 100644 --- a/transport-nethernet/build.gradle.kts +++ b/transport-nethernet/build.gradle.kts @@ -11,8 +11,11 @@ val nativePlatforms = listOf( dependencies { api(libs.bundles.netty) + api(libs.netty.codec.http) api(libs.expiringmap) api(libs.webrtc.java) + + implementation(libs.gson) nativePlatforms.forEach { platform -> implementation(libs.webrtc.java) { artifact { @@ -38,6 +41,6 @@ tasks.jar { } tasks.register("runDiscovery") { - mainClass.set("dev.kastle.netty.channel.nethernet.NetherNetScanner") + mainClass.set("dev.kastle.netty.util.nethernet.NetherNetScanner") classpath = sourceSets["main"].runtimeClasspath } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java index f88d687..1610bd7 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -29,8 +29,8 @@ public abstract class NetherNetChannel extends AbstractChannel { protected final NetherNetChannelConfig config; protected volatile RTCPeerConnection peerConnection; - protected volatile InetSocketAddress remoteAddress; - protected volatile InetSocketAddress localAddress; + protected volatile SocketAddress remoteAddress; + protected volatile SocketAddress localAddress; protected RTCDataChannel reliableChannel; protected RTCDataChannel unreliableChannel; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java index a8577ac..18538d5 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -1,39 +1,30 @@ package dev.kastle.netty.channel.nethernet; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling; import dev.kastle.webrtc.PeerConnectionFactory; import io.netty.channel.Channel; import io.netty.channel.ChannelFactory; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; +import java.util.function.Supplier; public class NetherNetChannelFactory implements ChannelFactory { - private final PeerConnectionFactory peerConnectionFactory; - private final Constructor constructor; - - public NetherNetChannelFactory(Class channelClass, PeerConnectionFactory factory) { - this.peerConnectionFactory = factory; - try { - this.constructor = channelClass.getConstructor(PeerConnectionFactory.class); - } catch (NoSuchMethodException e) { - throw new IllegalArgumentException("Channel class " + channelClass.getName() + " must have a public constructor accepting PeerConnectionFactory", e); - } + + private final Supplier channelCreator; + + private NetherNetChannelFactory(Supplier channelCreator) { + this.channelCreator = channelCreator; } @Override public T newChannel() { - try { - return constructor.newInstance(peerConnectionFactory); - } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException("Failed to create channel", e); - } + return channelCreator.get(); } public static ChannelFactory server(PeerConnectionFactory factory) { - return new NetherNetChannelFactory<>(NetherNetServerChannel.class, factory); + return new NetherNetChannelFactory<>(() -> new NetherNetServerChannel(factory)); } - public static ChannelFactory client(PeerConnectionFactory factory) { - return new NetherNetChannelFactory<>(NetherNetClientChannel.class, factory); + public static ChannelFactory client(PeerConnectionFactory factory, NetherNetSignaling signaling) { + return new NetherNetChannelFactory<>(() -> new NetherNetClientChannel(factory, signaling)); } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index 480cb9b..eb7ae40 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -1,10 +1,10 @@ package dev.kastle.netty.channel.nethernet; -import dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery; +import dev.kastle.netty.channel.nethernet.config.NetherNetAddress; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling; import dev.kastle.webrtc.CreateSessionDescriptionObserver; import dev.kastle.webrtc.PeerConnectionFactory; import dev.kastle.webrtc.PeerConnectionObserver; -import dev.kastle.webrtc.PortAllocatorConfig; import dev.kastle.webrtc.RTCBundlePolicy; import dev.kastle.webrtc.RTCConfiguration; import dev.kastle.webrtc.RTCDataChannel; @@ -13,6 +13,7 @@ import dev.kastle.webrtc.RTCDataChannelObserver; import dev.kastle.webrtc.RTCDataChannelState; import dev.kastle.webrtc.RTCIceCandidate; +import dev.kastle.webrtc.RTCIceServer; import dev.kastle.webrtc.RTCOfferOptions; import dev.kastle.webrtc.RTCPeerConnectionState; import dev.kastle.webrtc.RTCSdpType; @@ -27,39 +28,41 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; import java.nio.channels.ClosedChannelException; +import java.util.List; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; public class NetherNetClientChannel extends NetherNetChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetClientChannel.class); - private final PeerConnectionFactory factory; - - private final NetherNetDiscovery discovery; - private final long networkId; - private volatile long connectionId; + private final PeerConnectionFactory factory; + private final NetherNetSignaling signaling; + + private volatile long connectionId; // Session ID (Long) + private volatile String targetNetworkId; // Peer ID (String, for Realms) private volatile boolean handshakeComplete = false; private ChannelPromise connectPromise; - private static final int HANDSHAKE_TIMEOUT_MS = 500; + private static final int HANDSHAKE_TIMEOUT_MS = 3000; private volatile ScheduledFuture handshakeTimeoutTask; - - public NetherNetClientChannel() { - this(new PeerConnectionFactory()); - } - public NetherNetClientChannel(PeerConnectionFactory factory) { - this(ThreadLocalRandom.current().nextLong(), factory); + private volatile String localUfrag; + + public NetherNetClientChannel(NetherNetSignaling signaling) { + this(new PeerConnectionFactory(), signaling); } - public NetherNetClientChannel(long networkId, PeerConnectionFactory factory) { + public NetherNetClientChannel(PeerConnectionFactory factory, NetherNetSignaling signaling) { super(null, null, null); - this.networkId = networkId; this.factory = factory; + this.signaling = signaling; this.connectionId = ThreadLocalRandom.current().nextLong(); - this.discovery = new NetherNetDiscovery(this.networkId); + } + + public void setTargetNetworkId(String id) { + this.targetNetworkId = id; } @Override @@ -73,8 +76,7 @@ protected void doClose() throws Exception { if (handshakeTimeoutTask != null) { handshakeTimeoutTask.cancel(false); } - if (discovery != null) discovery.close(); - + if (signaling != null) signaling.close(); if (connectPromise != null && !connectPromise.isDone()) { connectPromise.tryFailure(new ClosedChannelException()); } @@ -85,217 +87,229 @@ protected AbstractUnsafe newUnsafe() { return new NetherNetClientUnsafe(); } - private void resetAndRetryHandshake() { - if (!isOpen()) return; + private class NetherNetClientUnsafe extends AbstractUnsafe { + @Override + public void connect(SocketAddress remote, SocketAddress local, ChannelPromise promise) { + if (!promise.setUncancellable() || !ensureOpen(promise)) return; + NetherNetClientChannel.this.connectPromise = promise; - log.debug("Handshake timed out/failed. Resetting ID and retrying..."); + if (remote instanceof NetherNetAddress) { + String targetId = ((NetherNetAddress) remote).getNetworkId(); + NetherNetClientChannel.this.setTargetNetworkId(targetId); + NetherNetClientChannel.this.remoteAddress = remote; + } else if (remote instanceof InetSocketAddress) { + NetherNetClientChannel.this.remoteAddress = (InetSocketAddress) remote; + NetherNetClientChannel.this.setTargetNetworkId("0"); // "0" triggers auto-discovery in signaling + } else { + promise.setFailure(new IllegalArgumentException("Unsupported address: " + remote.getClass())); + return; + } - if (handshakeTimeoutTask != null) { - handshakeTimeoutTask.cancel(false); - handshakeTimeoutTask = null; + eventLoop().execute(() -> startHandshake()); } + } + + private void startHandshake() { + if (!isOpen() || handshakeComplete) return; + + log.debug("Starting Handshake with Connection ID: {}", connectionId); + + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + handshakeTimeoutTask = eventLoop().schedule(() -> { + if (!handshakeComplete) { + log.info("Handshake timed out. Resetting and Retrying..."); + resetAndRetryHandshake(); + } + }, HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + signaling.setSignalHandler(connectionId, this::handleSignal); + + signaling.connect(remoteAddress).thenAcceptAsync(iceServers -> { + if (handshakeComplete) return; + try { + // If this is a retry, peerConnection might be null, so we recreate it + if (peerConnection == null) { + initWebRTC(iceServers); + createAndSendOffer(); + } + } catch (Exception e) { + log.error("WebRTC Init failed", e); + // We don't fail promise here; we let the timeout task trigger a retry + } + }, eventLoop()).exceptionally(e -> { + log.error("Signaling connection failed", e); + // Again, let timeout handle the retry loop + return null; + }); + } + + private void resetAndRetryHandshake() { + if (!isOpen()) return; if (peerConnection != null) { - peerConnection.close(); + peerConnection.close(); peerConnection = null; } - - if (discovery != null) { - discovery.unregisterSignalHandler(this.connectionId); - } + // Generate new ID for the new attempt this.connectionId = ThreadLocalRandom.current().nextLong(); - log.debug("Retrying connection with new Connection ID: {}", this.connectionId); - eventLoop().execute(() -> startHandshake(this.remoteAddress)); + // Restart flow + startHandshake(); } - private class NetherNetClientUnsafe extends AbstractUnsafe { - @Override - public void connect(SocketAddress remote, SocketAddress local, ChannelPromise promise) { - if (!promise.setUncancellable() || !ensureOpen(promise)) { - return; + private void initWebRTC(List iceServers) { + RTCConfiguration rtcConfig = new RTCConfiguration(); + rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; + + if (iceServers != null) { + for (NetherNetSignaling.IceServerInfo info : iceServers) { + RTCIceServer iceServer = new RTCIceServer(); + iceServer.urls = info.urls; + iceServer.username = info.username; + iceServer.password = info.password; + rtcConfig.iceServers.add(iceServer); } + } - if (!(remote instanceof InetSocketAddress)) { - promise.setFailure(new IllegalArgumentException("Unsupported address type")); - return; - } + peerConnection = factory.createPeerConnection(rtcConfig, new PeerConnectionObserver() { + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + // Wait until we have the ufrag (usually available immediately after createOffer) + if (localUfrag == null) { + log.warn("Generated ICE candidate before local ufrag was available. Skipping."); + return; + } - if (local instanceof InetSocketAddress) { - NetherNetClientChannel.this.localAddress = (InetSocketAddress) local; - } - - InetSocketAddress remoteAddress = (InetSocketAddress) remote; - NetherNetClientChannel.this.remoteAddress = remoteAddress; + String sdp = candidate.sdp.trim(); + + // Format: ufrag network-id network-cost 0 + StringBuilder sb = new StringBuilder(sdp); + sb.append(" ufrag ").append(localUfrag); + sb.append(" network-id ").append(signaling.getLocalNetworkId()); + sb.append(" network-cost 0"); - NetherNetClientChannel.this.connectPromise = promise; + String payload = NetherNetConstants.SIGNAL_CANDIDATE_ADD + " " + connectionId + " " + sb.toString(); + signaling.sendSignal(targetNetworkId, payload); + } - eventLoop().execute(() -> startHandshake(remoteAddress)); - } - } - - private void startHandshake(InetSocketAddress remoteAddress) { - try { - log.debug("Initializing WebRTC native components..."); - - if (!discovery.isActive()) { - log.debug("Binding discovery socket..."); - if (this.localAddress != null) { - discovery.bind(new InetSocketAddress(this.localAddress.getAddress(), 0)); - } else { - discovery.bind(0); + @Override + public void onConnectionChange(RTCPeerConnectionState state) { + if (state == RTCPeerConnectionState.FAILED) { + // Fast fail trigger: retry immediately instead of waiting for timeout + eventLoop().execute(() -> { + if (!handshakeComplete) resetAndRetryHandshake(); + }); } } - log.debug("Creating RTCPeerConnection..."); - RTCConfiguration rtcConfig = new RTCConfiguration(); - rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; + @Override public void onDataChannel(RTCDataChannel dataChannel) { } + }); - PortAllocatorConfig allocatorConfig = new PortAllocatorConfig(); - allocatorConfig.setDisableAdapterEnumeration(true); - allocatorConfig.setDisableTcp(true); - rtcConfig.portAllocatorConfig = allocatorConfig; + setupDataChannels(); + } - peerConnection = factory.createPeerConnection(rtcConfig, new PeerConnectionObserver() { - @Override - public void onIceCandidate(RTCIceCandidate candidate) { - discovery.sendSignal(remoteAddress, 0, - NetherNetConstants.SIGNAL_CANDIDATE_ADD + " " + connectionId + " " + candidate.sdp); - } + private String extractUfrag(String sdp) { + if (sdp == null) return ""; + for (String line : sdp.split("\\r?\\n")) { + line = line.trim(); + if (line.startsWith("a=ice-ufrag:")) { + return line.substring("a=ice-ufrag:".length()).trim(); + } + // Some implementations might omit 'a=' + if (line.startsWith("ice-ufrag:")) { + return line.substring("ice-ufrag:".length()).trim(); + } + } + log.warn("Could not find ice-ufrag in local SDP!"); + return ""; + } - @Override - public void onConnectionChange(RTCPeerConnectionState state) { - log.debug("WebRTC Connection State: {}", state); - if (state == RTCPeerConnectionState.FAILED) { - close(); + private void createAndSendOffer() { + if (peerConnection == null) return; + peerConnection.createOffer(new RTCOfferOptions(), new CreateSessionDescriptionObserver() { + @Override + public void onSuccess(RTCSessionDescription description) { + if (peerConnection == null) return; + NetherNetClientChannel.this.localUfrag = extractUfrag(description.sdp); + peerConnection.setLocalDescription(description, new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + String payload = NetherNetConstants.SIGNAL_CONNECT_REQUEST + " " + connectionId + " " + description.sdp; + signaling.sendSignal(targetNetworkId, payload); } - } + @Override public void onFailure(String error) { /* Retry handled by timeout */ } + }); + } + @Override public void onFailure(String error) { /* Retry handled by timeout */ } + }); + } - @Override public void onDataChannel(RTCDataChannel dataChannel) { } - }); - - log.debug("Creating Data Channels..."); - RTCDataChannelInit reliableInit = new RTCDataChannelInit(); - reliableInit.ordered = true; - reliableInit.protocol = NetherNetConstants.RELIABLE_CHANNEL_LABEL; - - RTCDataChannelInit unreliableInit = new RTCDataChannelInit(); - unreliableInit.ordered = false; - unreliableInit.maxRetransmits = 0; - - RTCDataChannel reliable = peerConnection.createDataChannel(NetherNetConstants.RELIABLE_CHANNEL_LABEL, reliableInit); - RTCDataChannel unreliable = peerConnection.createDataChannel(NetherNetConstants.UNRELIABLE_CHANNEL_LABEL, unreliableInit); - - reliable.registerObserver(new RTCDataChannelObserver() { - @Override - public void onStateChange() { - if (reliable.getState() == RTCDataChannelState.OPEN) { - // Switch back to Netty Thread to complete the connection safely - eventLoop().execute(() -> { - if (!handshakeComplete) { - log.debug("NetherNet Connection Fully Established (via Observer)."); - handshakeComplete = true; - - setDataChannels(reliable, unreliable); - - if (connectPromise != null && !connectPromise.isDone()) { - connectPromise.trySuccess(); - } - pipeline().fireChannelActive(); - } - }); - } - } + private void handleSignal(String signal) { + String[] parts = signal.split(" ", 3); + if (parts.length < 3) return; + String type = parts[0]; + String data = parts[2]; + + eventLoop().execute(() -> { + if (peerConnection == null) return; + switch (type) { + case NetherNetConstants.SIGNAL_CONNECT_RESPONSE: + peerConnection.setRemoteDescription(new RTCSessionDescription(RTCSdpType.ANSWER, data), new SetSessionDescriptionObserver() { + @Override public void onSuccess() {} + @Override public void onFailure(String e) { /* Retry handled by timeout */ } + }); + break; + case NetherNetConstants.SIGNAL_CANDIDATE_ADD: + peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); + break; + case NetherNetConstants.SIGNAL_CONNECT_ERROR: + // Server rejected us (e.g. offline). Reset immediately. + resetAndRetryHandshake(); + break; + } + }); + } - @Override public void onBufferedAmountChange(long previousAmount) {} - @Override public void onMessage(RTCDataChannelBuffer buffer) { - // This shouldn't happen during handshake, but if it does, release the buffer to avoid leaks. - // Real data handling happens after setDataChannels swaps the observer. - ReferenceCountUtil.release(buffer); - } - }); - - discovery.registerSignalHandler(connectionId, (signal) -> { - String[] parts = signal.split(" ", 3); - if (parts.length < 3) return; - String type = parts[0]; - String data = parts[2]; - - eventLoop().execute(() -> { - switch (type) { - case NetherNetConstants.SIGNAL_CONNECT_RESPONSE: - log.debug("Received CONNECT_RESPONSE (Answer)"); - peerConnection.setRemoteDescription( - new RTCSessionDescription(RTCSdpType.ANSWER, data), - new SetSessionDescriptionObserver() { - @Override public void onSuccess() {} - @Override public void onFailure(String error) { - log.error("RemoteDesc error: {}", error); - close(); - } - } - ); - break; - case NetherNetConstants.SIGNAL_CANDIDATE_ADD: - peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); - break; - case NetherNetConstants.SIGNAL_CONNECT_ERROR: - log.error("Received CONNECT_ERROR from server: {}", data); - close(); - break; - } - }); - }); - - log.debug("Creating Offer..."); - peerConnection.createOffer(new RTCOfferOptions(), new CreateSessionDescriptionObserver() { - @Override - public void onSuccess(RTCSessionDescription description) { - peerConnection.setLocalDescription(description, new SetSessionDescriptionObserver() { - @Override - public void onSuccess() { - performDiscoveryAndConnect(remoteAddress, description.sdp); - } - @Override public void onFailure(String error) { - log.error("LocalDesc error: {}", error); - close(); + private void setupDataChannels() { + RTCDataChannelInit reliableInit = new RTCDataChannelInit(); + reliableInit.ordered = true; + reliableInit.protocol = NetherNetConstants.RELIABLE_CHANNEL_LABEL; + + RTCDataChannelInit unreliableInit = new RTCDataChannelInit(); + unreliableInit.ordered = false; + unreliableInit.maxRetransmits = 0; + + RTCDataChannel reliable = peerConnection.createDataChannel(NetherNetConstants.RELIABLE_CHANNEL_LABEL, reliableInit); + RTCDataChannel unreliable = peerConnection.createDataChannel(NetherNetConstants.UNRELIABLE_CHANNEL_LABEL, unreliableInit); + + reliable.registerObserver(new RTCDataChannelObserver() { + @Override + public void onStateChange() { + if (reliable.getState() == RTCDataChannelState.OPEN) { + eventLoop().execute(() -> { + if (!handshakeComplete) { + log.info("NetherNet Connection Established!"); + handshakeComplete = true; + + // Cancel timeout now that we are done + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); + } + + setDataChannels(reliable, unreliable); + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.trySuccess(); + } + pipeline().fireChannelActive(); } }); } - @Override public void onFailure(String error) { - log.error("CreateOffer error: {}", error); - close(); - } - }); - - } catch (Exception e) { - log.error("Handshake initialization failed", e); - close(); - } - } - - private void performDiscoveryAndConnect(InetSocketAddress remote, String offerSdp) { - log.debug("Sending Discovery Request to {}", remote); - - if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); - handshakeTimeoutTask = eventLoop().schedule(() -> { - if (!handshakeComplete) { - resetAndRetryHandshake(); } - }, HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS); - - discovery.sendDiscoveryRequest(remote, (serverNetworkId, payload) -> { - try { - log.debug("Found Server NetworkID: {}", serverNetworkId); - discovery.sendSignal( - remote, - serverNetworkId, - NetherNetConstants.SIGNAL_CONNECT_REQUEST + " " + connectionId + " " + offerSdp - ); - } finally { - ReferenceCountUtil.release(payload); + @Override public void onBufferedAmountChange(long previousAmount) {} + @Override public void onMessage(RTCDataChannelBuffer buffer) { + ReferenceCountUtil.release(buffer); } - }); + }); } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index edd2b2a..78c0185 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -1,7 +1,7 @@ package dev.kastle.netty.channel.nethernet; import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; -import dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetDiscovery; import dev.kastle.webrtc.CreateSessionDescriptionObserver; import dev.kastle.webrtc.PeerConnectionFactory; import dev.kastle.webrtc.PeerConnectionObserver; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java new file mode 100644 index 0000000..f0385d1 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java @@ -0,0 +1,33 @@ +package dev.kastle.netty.channel.nethernet.config; + +import java.net.SocketAddress; + +public class NetherNetAddress extends SocketAddress { + private final String networkId; + + public NetherNetAddress(long networkId) { + this.networkId = Long.toUnsignedString(networkId); + } + + public NetherNetAddress(String networkId) { + this.networkId = networkId; + } + + public String getNetworkId() { + return networkId; + } + + /** + * Tries to parse the Network ID as a long. + * @return the long value + * @throws NumberFormatException if the ID is not a valid unsigned long string (e.g. Realms ID). + */ + public long getNetworkIdAsLong() { + return Long.parseUnsignedLong(networkId); + } + + @Override + public String toString() { + return "NetherNetAddress(" + networkId + ")"; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java similarity index 99% rename from transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java rename to transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java index aeb9ade..1ddd885 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/NetherNetDiscovery.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java @@ -1,4 +1,4 @@ -package dev.kastle.netty.handler.codec.nethernet; +package dev.kastle.netty.channel.nethernet.signaling; import dev.kastle.netty.channel.nethernet.NetherNetConstants; import io.netty.bootstrap.Bootstrap; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java new file mode 100644 index 0000000..b961c20 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -0,0 +1,127 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import io.netty.util.ReferenceCountUtil; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +public class NetherNetDiscoverySignaling implements NetherNetSignaling { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscoverySignaling.class); + + private final NetherNetDiscovery discovery; + private final InetSocketAddress bindAddress; + private final String localNetworkId; + + // State captured after connect + private volatile InetSocketAddress remoteAddress; + private final AtomicReference discoveredServerId = new AtomicReference<>(null); + + public NetherNetDiscoverySignaling() { + this(ThreadLocalRandom.current().nextLong(), new InetSocketAddress(0)); + } + + public NetherNetDiscoverySignaling(long localNetworkId) { + this(localNetworkId, new InetSocketAddress(0)); + } + + public NetherNetDiscoverySignaling(long localNetworkId, InetSocketAddress bindAddress) { + this.localNetworkId = Long.toUnsignedString(localNetworkId); + this.discovery = new NetherNetDiscovery(localNetworkId); + this.bindAddress = bindAddress; + } + + @Override + public String getLocalNetworkId() { + return this.localNetworkId; + } + + @Override + public CompletableFuture> connect(SocketAddress remote) { + CompletableFuture> future = new CompletableFuture<>(); + + if (!(remote instanceof InetSocketAddress)) { + future.completeExceptionally(new IllegalArgumentException("Discovery requires InetSocketAddress")); + return future; + } + + this.remoteAddress = (InetSocketAddress) remote; + + try { + if (!discovery.isActive()) { + log.info("Binding NetherNet Discovery to {}", bindAddress); + discovery.bind(bindAddress); + } + + log.debug("Sending Discovery Request to {}", remote); + + // Send request and register the callback to capture the ID + discovery.sendDiscoveryRequest(this.remoteAddress, (serverNetworkId, payload) -> { + try { + log.info("Discovery Response Received! Server NetworkID: {}", serverNetworkId); + + // Capture the ID so we can use it for signaling later + discoveredServerId.set(Long.toUnsignedString(serverNetworkId)); + + future.complete(Collections.emptyList()); + } catch (Exception e) { + log.error("Error processing discovery response", e); + future.completeExceptionally(e); + } finally { + ReferenceCountUtil.release(payload); + } + }); + } catch (Exception e) { + log.error("Failed to send discovery request", e); + future.completeExceptionally(e); + } + + return future; + } + + @Override + public void sendSignal(String targetNetworkId, String data) { + if (remoteAddress == null) { + log.warn("Cannot send signal: Remote address not set (connect() not called?)"); + return; + } + + // If the Channel passed '0' (unknown), use the one we discovered. + String actualIdStr = targetNetworkId; + if (actualIdStr == null || actualIdStr.equals("0")) { + actualIdStr = discoveredServerId.get(); + } + + if (actualIdStr == null) { + log.warn("Cannot send signal: Unknown Server Network ID."); + return; + } + + log.trace("Sending Signal to {} (ID: {}): {}", remoteAddress, actualIdStr, data); + try { + // LAN protocol strictly requires a Long ID. + // If we are trying to connect to a Realm (String ID) via LAN signaling, this is invalid configuration. + long id = Long.parseUnsignedLong(actualIdStr); + discovery.sendSignal(remoteAddress, id, data); + } catch (NumberFormatException e) { + log.error("Cannot send LAN signal to non-numeric Network ID: {}", actualIdStr); + } + } + + @Override + public void setSignalHandler(long connectionId, Consumer handler) { + discovery.registerSignalHandler(connectionId, handler); + } + + @Override + public void close() { + discovery.close(); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java new file mode 100644 index 0000000..e544e73 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java @@ -0,0 +1,39 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import java.net.SocketAddress; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +public interface NetherNetSignaling extends AutoCloseable { + + /** + * Connects to the signaling medium. + */ + CompletableFuture> connect(SocketAddress remoteAddress); + + /** + * Sends a signaling message to the remote peer. + * + * @param targetNetworkId The Network ID of the destination (String to support Realms). + * @param data The raw signaling payload. + */ + void sendSignal(String targetNetworkId, String data); + + void setSignalHandler(long connectionId, Consumer handler); + + /** + * Returns the Local Network ID of this client as a String. + * This is required for formatting the 'candidate:' string in SDP. + */ + String getLocalNetworkId(); + + @Override + void close(); + + class IceServerInfo { + public String username; + public String password; + public List urls; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java new file mode 100644 index 0000000..2d0d151 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -0,0 +1,217 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; +import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.SocketAddress; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +public class NetherNetXboxSignaling extends SimpleChannelInboundHandler implements NetherNetSignaling { + private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetXboxSignaling.class); + private static final Gson gson = new Gson(); + + private final String xboxToken; + private final String localNetworkId; + private final URI uri; + private final EventLoopGroup eventLoopGroup; + + private Channel channel; + private CompletableFuture> connectFuture; + + private final Map> handlers = new ConcurrentHashMap<>(); + + public NetherNetXboxSignaling(String localNetworkId, String xboxToken) { + this.localNetworkId = localNetworkId; + this.xboxToken = xboxToken; + this.uri = URI.create("wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling/" + localNetworkId); + this.eventLoopGroup = new NioEventLoopGroup(1); + } + + public NetherNetXboxSignaling(long localNetworkId, String xboxToken) { + this(Long.toUnsignedString(localNetworkId), xboxToken); + } + + public NetherNetXboxSignaling(String xboxToken) { + this(Long.toUnsignedString(ThreadLocalRandom.current().nextLong()), xboxToken); + } + + @Override + public String getLocalNetworkId() { + return this.localNetworkId; + } + + @Override + public synchronized CompletableFuture> connect(SocketAddress remoteAddress) { + // If already connecting or connected, return the existing future + if (connectFuture != null) { + return connectFuture; + } + + connectFuture = new CompletableFuture<>(); + + try { + SslContext sslCtx = SslContextBuilder.forClient().build(); + WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory.newHandshaker( + uri, WebSocketVersion.V13, null, false, + new DefaultHttpHeaders().add("Authorization", xboxToken) + ); + + Bootstrap b = new Bootstrap(); + b.group(eventLoopGroup) + .channel(NioSocketChannel.class) + .handler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ChannelPipeline p = ch.pipeline(); + p.addLast(sslCtx.newHandler(ch.alloc(), uri.getHost(), 443)); + p.addLast(new HttpClientCodec(), new HttpObjectAggregator(8192)); + p.addLast("ws-handshake", new WebSocketClientProtocolHandler(handshaker)); + p.addLast("handler", NetherNetXboxSignaling.this); + } + }); + + this.channel = b.connect(uri.getHost(), 443).sync().channel(); + } catch (Exception e) { + connectFuture.completeExceptionally(e); + } + return connectFuture; + } + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) { + log.info("NetherNet Signaling WebSocket Connected"); + startPingLoop(ctx); + } else { + super.userEventTriggered(ctx, evt); + } + } + + private void startPingLoop(ChannelHandlerContext ctx) { + ctx.executor().scheduleAtFixedRate(() -> { + JsonObject ping = new JsonObject(); + ping.addProperty("Type", 0); // RequestType::Ping + ctx.writeAndFlush(new TextWebSocketFrame(gson.toJson(ping))); + }, 5, 5, TimeUnit.SECONDS); + } + + @Override + public void sendSignal(String targetNetworkId, String data) { + if (channel != null && channel.isActive()) { + JsonObject msg = new JsonObject(); + msg.addProperty("Type", 1); + msg.addProperty("To", targetNetworkId); + msg.addProperty("Message", data); + channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(msg))); + } + } + + @Override + public void setSignalHandler(long connectionId, Consumer handler) { + this.handlers.put(connectionId, handler); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { + String text = frame.text(); + try { + JsonObject json = gson.fromJson(text, JsonObject.class); + if (!json.has("Type")) return; + + int type = json.get("Type").getAsInt(); + switch (type) { + case 2: // Credentials + if (json.has("Message") && !connectFuture.isDone()) { + connectFuture.complete(parseTurnServers(json.get("Message").getAsString())); + } + break; + case 1: // Signal + if (json.has("Message")) { + String rawMsg = json.get("Message").getAsString(); + dispatchSignal(rawMsg); + } + break; + } + } catch (Exception e) { + log.error("Signaling error", e); + } + } + + private void dispatchSignal(String rawMsg) { + // Format: TYPE CONNECTION_ID PAYLOAD + try { + String[] parts = rawMsg.split(" ", 3); + if (parts.length >= 2) { + long connectionId = Long.parseUnsignedLong(parts[1]); + Consumer handler = handlers.get(connectionId); + if (handler != null) { + handler.accept(rawMsg); + } + } + } catch (Exception e) { + log.debug("Failed to dispatch signal: {}", rawMsg); + } + } + + private List parseTurnServers(String jsonString) { + List result = new ArrayList<>(); + try { + JsonObject root = gson.fromJson(jsonString, JsonObject.class); + if (root.has("TurnAuthServers")) { + JsonArray servers = root.getAsJsonArray("TurnAuthServers"); + for (JsonElement el : servers) { + JsonObject server = el.getAsJsonObject(); + if (server.has("Urls")) { + List urls = new ArrayList<>(); + server.getAsJsonArray("Urls").forEach(u -> urls.add(u.getAsString())); + + IceServerInfo info = new IceServerInfo(); + info.urls = urls; + if (server.has("Username")) info.username = server.get("Username").getAsString(); + if (server.has("Password")) info.password = server.get("Password").getAsString(); + result.add(info); + } + } + } + } catch (Exception ignored) {} + return result; + } + + @Override + public void close() { + if (channel != null) channel.close(); + eventLoopGroup.shutdownGracefully(); + } +} \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java new file mode 100644 index 0000000..6bc5ae8 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/package-info.java @@ -0,0 +1 @@ +package dev.kastle.netty.channel.nethernet.signaling; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/package-info.java b/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/package-info.java deleted file mode 100644 index d3b9711..0000000 --- a/transport-nethernet/src/main/java/dev/kastle/netty/handler/codec/nethernet/package-info.java +++ /dev/null @@ -1 +0,0 @@ -package dev.kastle.netty.handler.codec.nethernet; diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java index 388e204..d97e9e0 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java @@ -1,7 +1,7 @@ package dev.kastle.netty.util.nethernet; import dev.kastle.netty.channel.nethernet.NetherNetConstants; -import dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetDiscovery; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; @@ -16,7 +16,7 @@ public class NetherNetScanner { public static void main(String[] args) throws Exception { long myNetworkId = ThreadLocalRandom.current().nextLong(); - dev.kastle.netty.handler.codec.nethernet.NetherNetDiscovery discovery = new NetherNetDiscovery(myNetworkId); + dev.kastle.netty.channel.nethernet.signaling.NetherNetDiscovery discovery = new NetherNetDiscovery(myNetworkId); discovery.bind(new InetSocketAddress("::", 0)); From 5add21bf402d5f804c33d97e9abe05f808a4b7a4 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Thu, 1 Jan 2026 20:08:57 -0800 Subject: [PATCH 07/22] Fix issues with connectionId being signed in SDP strings; fix LAN server channel Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../nethernet/NetherNetChannelFactory.java | 9 +- .../nethernet/NetherNetClientChannel.java | 75 ++++++--- .../channel/nethernet/NetherNetConstants.java | 12 ++ .../nethernet/NetherNetServerChannel.java | 151 ++++++++++-------- .../signaling/NetherNetClientSignaling.java | 12 ++ .../signaling/NetherNetDiscovery.java | 88 +++++++--- .../NetherNetDiscoverySignaling.java | 60 +++++-- .../signaling/NetherNetServerSignaling.java | 104 ++++++++++++ .../signaling/NetherNetSignaling.java | 9 +- .../signaling/NetherNetXboxSignaling.java | 55 +++++-- 10 files changed, 424 insertions(+), 151 deletions(-) create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java index 18538d5..e5494a4 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -1,6 +1,7 @@ package dev.kastle.netty.channel.nethernet; -import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; import dev.kastle.webrtc.PeerConnectionFactory; import io.netty.channel.Channel; import io.netty.channel.ChannelFactory; @@ -20,11 +21,11 @@ public T newChannel() { return channelCreator.get(); } - public static ChannelFactory server(PeerConnectionFactory factory) { - return new NetherNetChannelFactory<>(() -> new NetherNetServerChannel(factory)); + public static ChannelFactory server(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { + return new NetherNetChannelFactory<>(() -> new NetherNetServerChannel(factory, signaling)); } - public static ChannelFactory client(PeerConnectionFactory factory, NetherNetSignaling signaling) { + public static ChannelFactory client(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { return new NetherNetChannelFactory<>(() -> new NetherNetClientChannel(factory, signaling)); } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index eb7ae40..3d29a56 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -1,6 +1,7 @@ package dev.kastle.netty.channel.nethernet; import dev.kastle.netty.channel.nethernet.config.NetherNetAddress; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling; import dev.kastle.webrtc.CreateSessionDescriptionObserver; import dev.kastle.webrtc.PeerConnectionFactory; @@ -36,7 +37,7 @@ public class NetherNetClientChannel extends NetherNetChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetClientChannel.class); private final PeerConnectionFactory factory; - private final NetherNetSignaling signaling; + private final NetherNetClientSignaling signaling; private volatile long connectionId; // Session ID (Long) private volatile String targetNetworkId; // Peer ID (String, for Realms) @@ -50,15 +51,15 @@ public class NetherNetClientChannel extends NetherNetChannel { private volatile String localUfrag; - public NetherNetClientChannel(NetherNetSignaling signaling) { + public NetherNetClientChannel(NetherNetClientSignaling signaling) { this(new PeerConnectionFactory(), signaling); } - public NetherNetClientChannel(PeerConnectionFactory factory, NetherNetSignaling signaling) { + public NetherNetClientChannel(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { super(null, null, null); this.factory = factory; this.signaling = signaling; - this.connectionId = ThreadLocalRandom.current().nextLong(); + this.connectionId = this.cycleConnectionId(); } public void setTargetNetworkId(String id) { @@ -76,7 +77,10 @@ protected void doClose() throws Exception { if (handshakeTimeoutTask != null) { handshakeTimeoutTask.cancel(false); } - if (signaling != null) signaling.close(); + if (signaling != null) { + signaling.removeSignalHandler(this.connectionId); + signaling.close(); + } if (connectPromise != null && !connectPromise.isDone()) { connectPromise.tryFailure(new ClosedChannelException()); } @@ -112,17 +116,17 @@ public void connect(SocketAddress remote, SocketAddress local, ChannelPromise pr private void startHandshake() { if (!isOpen() || handshakeComplete) return; - log.debug("Starting Handshake with Connection ID: {}", connectionId); + log.debug("Starting Handshake with Connection ID: {}", Long.toUnsignedString(this.connectionId)); if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); handshakeTimeoutTask = eventLoop().schedule(() -> { if (!handshakeComplete) { - log.info("Handshake timed out. Resetting and Retrying..."); + log.debug("Handshake timed out. Resetting and Retrying..."); resetAndRetryHandshake(); } }, HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS); - signaling.setSignalHandler(connectionId, this::handleSignal); + signaling.setSignalHandler(this.connectionId, this::handleSignal); signaling.connect(remoteAddress).thenAcceptAsync(iceServers -> { if (handshakeComplete) return; @@ -151,8 +155,11 @@ private void resetAndRetryHandshake() { peerConnection = null; } + // Remove handler for the failed connection ID + signaling.removeSignalHandler(this.connectionId); + // Generate new ID for the new attempt - this.connectionId = ThreadLocalRandom.current().nextLong(); + this.cycleConnectionId(); // Restart flow startHandshake(); @@ -189,8 +196,10 @@ public void onIceCandidate(RTCIceCandidate candidate) { sb.append(" network-id ").append(signaling.getLocalNetworkId()); sb.append(" network-cost 0"); - String payload = NetherNetConstants.SIGNAL_CANDIDATE_ADD + " " + connectionId + " " + sb.toString(); - signaling.sendSignal(targetNetworkId, payload); + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, sb.toString()) + ); } @Override @@ -235,8 +244,10 @@ public void onSuccess(RTCSessionDescription description) { peerConnection.setLocalDescription(description, new SetSessionDescriptionObserver() { @Override public void onSuccess() { - String payload = NetherNetConstants.SIGNAL_CONNECT_REQUEST + " " + connectionId + " " + description.sdp; - signaling.sendSignal(targetNetworkId, payload); + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalConnectRequest(connectionId, description.sdp) + ); } @Override public void onFailure(String error) { /* Retry handled by timeout */ } }); @@ -247,26 +258,41 @@ public void onSuccess() { private void handleSignal(String signal) { String[] parts = signal.split(" ", 3); - if (parts.length < 3) return; + if (parts.length < 2) return; // Allow length 2 for ERROR packets without payload String type = parts[0]; - String data = parts[2]; + String idStr = parts[1].trim(); + String data = parts.length > 2 ? parts[2] : ""; + + // Verify this signal belongs to the current attempt + try { + long signalId = Long.parseUnsignedLong(idStr); + if (signalId != this.connectionId) { + log.debug("Ignored stale signal for ID {}", idStr); + return; + } + } catch (NumberFormatException e) { + return; + } eventLoop().execute(() -> { if (peerConnection == null) return; + if (!isOpen() || handshakeComplete) return; + switch (type) { - case NetherNetConstants.SIGNAL_CONNECT_RESPONSE: + case NetherNetConstants.SIGNAL_CONNECT_RESPONSE -> { peerConnection.setRemoteDescription(new RTCSessionDescription(RTCSdpType.ANSWER, data), new SetSessionDescriptionObserver() { @Override public void onSuccess() {} @Override public void onFailure(String e) { /* Retry handled by timeout */ } }); - break; - case NetherNetConstants.SIGNAL_CANDIDATE_ADD: + } + case NetherNetConstants.SIGNAL_CANDIDATE_ADD -> { peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); - break; - case NetherNetConstants.SIGNAL_CONNECT_ERROR: + } + case NetherNetConstants.SIGNAL_CONNECT_ERROR -> { // Server rejected us (e.g. offline). Reset immediately. + log.debug("Received SIGNAL_CONNECT_ERROR for {}", Long.toUnsignedString(this.connectionId)); resetAndRetryHandshake(); - break; + } } }); } @@ -289,7 +315,7 @@ public void onStateChange() { if (reliable.getState() == RTCDataChannelState.OPEN) { eventLoop().execute(() -> { if (!handshakeComplete) { - log.info("NetherNet Connection Established!"); + log.debug("NetherNet Connection Established!"); handshakeComplete = true; // Cancel timeout now that we are done @@ -312,4 +338,9 @@ public void onStateChange() { } }); } + + private long cycleConnectionId() { + this.connectionId = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE); + return this.connectionId; + } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java index 32692cf..8a3caa6 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -111,4 +111,16 @@ public static ByteBuf decryptDiscoveryPacket(ByteBuf input) throws Exception { return payload; } + + public static String buildSignalConnectRequest(long connectionId, String sdp) { + return SIGNAL_CONNECT_REQUEST + " " + Long.toUnsignedString(connectionId) + " " + sdp; + } + + public static String buildSignalConnectResponse(long connectionId, String sdp) { + return SIGNAL_CONNECT_RESPONSE + " " + Long.toUnsignedString(connectionId) + " " + sdp; + } + + public static String buildSignalCandidateAdd(long connectionId, String candidateSdp) { + return SIGNAL_CANDIDATE_ADD + " " + Long.toUnsignedString(connectionId) + " " + candidateSdp; + } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index 78c0185..6f2f451 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -1,7 +1,7 @@ package dev.kastle.netty.channel.nethernet; import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; -import dev.kastle.netty.channel.nethernet.signaling.NetherNetDiscovery; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; import dev.kastle.webrtc.CreateSessionDescriptionObserver; import dev.kastle.webrtc.PeerConnectionFactory; import dev.kastle.webrtc.PeerConnectionObserver; @@ -24,7 +24,6 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; -import java.util.concurrent.ThreadLocalRandom; public class NetherNetServerChannel extends AbstractServerChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetServerChannel.class); @@ -32,22 +31,17 @@ public class NetherNetServerChannel extends AbstractServerChannel { private final NetherNetChannelConfig config = new NetherNetChannelConfig(this); private final PeerConnectionFactory factory; + private final NetherNetServerSignaling signaling; - private NetherNetDiscovery discovery; private InetSocketAddress localAddress; - private long networkId; - public NetherNetServerChannel() { - this(new PeerConnectionFactory()); + public NetherNetServerChannel(NetherNetServerSignaling signaling) { + this(new PeerConnectionFactory(), signaling); } - public NetherNetServerChannel(PeerConnectionFactory factory) { - this(ThreadLocalRandom.current().nextLong(), factory); - } - - public NetherNetServerChannel(long networkId, PeerConnectionFactory factory) { + public NetherNetServerChannel(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { this.factory = factory; - this.networkId = networkId; + this.signaling = signaling; } @Override @@ -55,65 +49,35 @@ protected void doBind(SocketAddress localAddress) throws Exception { if (!(localAddress instanceof InetSocketAddress)) throw new IllegalArgumentException("Unsupported address type"); this.localAddress = (InetSocketAddress) localAddress; - this.discovery = new NetherNetDiscovery(this.networkId); - - this.discovery.setNewConnectionHandler((connectionId, offerSdp) -> { - // TODO: extract the sender's network ID from the packet context - acceptConnection(connectionId, offerSdp, "0"); + this.signaling.setNewConnectionHandler((connectionId, remoteNetworkId, offerSdp) -> { + acceptConnection(connectionId, offerSdp, remoteNetworkId); }); - this.discovery.bind(); - - // TODO: Make configurable - this.discovery.setPongData("NetherNet Server", "World", 1, 0, 10); + this.signaling.bind(localAddress); } public void acceptConnection(long connectionId, String offerSdp, String remoteNetworkId) { RTCConfiguration rtcConfig = new RTCConfiguration(); rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; - RTCPeerConnection pc = factory.createPeerConnection(rtcConfig, new PeerConnectionObserver() { - @Override - public void onIceCandidate(RTCIceCandidate candidate) { - String candidateString = candidate.sdp; - discovery.sendSignal(localAddress, Long.parseLong(remoteNetworkId), - NetherNetConstants.SIGNAL_CANDIDATE_ADD + " " + connectionId + " " + candidateString); - } - - @Override - public void onConnectionChange(RTCPeerConnectionState state) { - log.info("Connection {} state changed: {}", connectionId, state); - if (state == RTCPeerConnectionState.FAILED || state == RTCPeerConnectionState.CLOSED) { - discovery.unregisterSignalHandler(connectionId); - } - } - - @Override - public void onDataChannel(RTCDataChannel dataChannel) { - // Server accepts channels created by client (handled in NetherNetChannel) - } - }); + ServerPeerConnectionObserver observer = new ServerPeerConnectionObserver(connectionId, remoteNetworkId); + RTCPeerConnection pc = factory.createPeerConnection(rtcConfig, observer); NetherNetChildChannel child = new NetherNetChildChannel(this, pc, new InetSocketAddress(0), localAddress); + observer.setChildChannel(child); // Register Signal Handler - discovery.registerSignalHandler(connectionId, (signal) -> { + signaling.setSignalHandler(connectionId, (signal) -> { String[] parts = signal.split(" ", 3); if (parts.length < 3) return; - String type = parts[0]; String data = parts[2]; switch (type) { - case NetherNetConstants.SIGNAL_CANDIDATE_ADD: - // Hardcode sdpMid to "0" and sdpMLineIndex to 0 based on NetherNet spec - RTCIceCandidate candidate = new RTCIceCandidate("0", 0, data); - pc.addIceCandidate(candidate); - break; - case NetherNetConstants.SIGNAL_CONNECT_ERROR: - log.error("Connection {} received error: {}", connectionId, data); + case NetherNetConstants.SIGNAL_CANDIDATE_ADD -> + pc.addIceCandidate(new RTCIceCandidate("0", 0, data)); + case NetherNetConstants.SIGNAL_CONNECT_ERROR -> child.close(); - break; } }); @@ -127,30 +91,85 @@ public void onSuccess(RTCSessionDescription description) { pc.setLocalDescription(description, new SetSessionDescriptionObserver() { @Override public void onSuccess() { - discovery.sendSignal(localAddress, Long.parseLong(remoteNetworkId), - NetherNetConstants.SIGNAL_CONNECT_RESPONSE + " " + connectionId + " " + description.sdp); - + signaling.sendSignal( + remoteNetworkId, + NetherNetConstants.buildSignalConnectResponse(connectionId, description.sdp) + ); pipeline().fireChannelRead(child); } - @Override public void onFailure(String error) { - log.error("Failed to set local description: {}", error); - } + @Override public void onFailure(String error) { log.error("SetLocalDesc failed: {}", error); } }); } - @Override public void onFailure(String error) { - log.error("Failed to create answer: {}", error); - } + @Override public void onFailure(String error) { log.error("CreateAnswer failed: {}", error); } }); } - @Override public void onFailure(String error) { - log.error("Failed to set remote description (Offer): {}", error); - } + @Override public void onFailure(String error) { log.error("SetRemoteDesc failed: {}", error); } }); } + /** + * Observer to handle Data Channel creation from the client. + */ + private class ServerPeerConnectionObserver implements PeerConnectionObserver { + private final long connectionId; + private final String remoteNetworkId; + private NetherNetChildChannel child; + + private RTCDataChannel reliable; + private RTCDataChannel unreliable; + + public ServerPeerConnectionObserver(long connectionId, String remoteNetworkId) { + this.connectionId = connectionId; + this.remoteNetworkId = remoteNetworkId; + } + + public void setChildChannel(NetherNetChildChannel child) { + this.child = child; + checkDataChannels(); + } + + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + signaling.sendSignal( + remoteNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate.sdp) + ); + } + + @Override + public void onConnectionChange(RTCPeerConnectionState state) { + log.debug("Connection {} state changed: {}", Long.toUnsignedString(this.connectionId), state); + } + + @Override + public void onDataChannel(RTCDataChannel dataChannel) { + String label = dataChannel.getLabel(); + log.debug("Received Data Channel: {}", label); + + if (NetherNetConstants.RELIABLE_CHANNEL_LABEL.equals(label)) { + this.reliable = dataChannel; + } else if (NetherNetConstants.UNRELIABLE_CHANNEL_LABEL.equals(label)) { + this.unreliable = dataChannel; + } + + checkDataChannels(); + } + + private void checkDataChannels() { + if (child != null && reliable != null && unreliable != null) { + log.debug("Data Channels established for {}", Long.toUnsignedString(this.connectionId)); + child.setDataChannels(reliable, unreliable); + + if (child.pipeline() != null) { + child.pipeline().fireChannelActive(); + } + } + } + } + @Override protected void doClose() throws Exception { - if (discovery != null) discovery.close(); + signaling.close(); factory.dispose(); } @@ -174,7 +193,7 @@ protected boolean isCompatible(EventLoop loop) { @Override public boolean isOpen() { - return discovery != null; + return true; } @Override diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java new file mode 100644 index 0000000..6198da5 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java @@ -0,0 +1,12 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import java.net.SocketAddress; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface NetherNetClientSignaling extends NetherNetSignaling { + /** + * Connects to the signaling medium (Client mode). + */ + CompletableFuture> connect(SocketAddress remoteAddress); +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java index 1ddd885..53a43d1 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java @@ -1,9 +1,9 @@ package dev.kastle.netty.channel.nethernet.signaling; import dev.kastle.netty.channel.nethernet.NetherNetConstants; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling.PongData; import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; @@ -19,6 +19,7 @@ import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.util.HexFormat; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -30,9 +31,10 @@ public class NetherNetDiscovery extends SimpleChannelInboundHandler> signalHandlers = new ConcurrentHashMap<>(); + private final Map peerAddresses = new ConcurrentHashMap<>(); private Channel channel; private byte[] pongData; - private BiConsumer newConnectionHandler; + private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; private BiConsumer discoveryCallback; public NetherNetDiscovery(long networkId) { @@ -86,23 +88,23 @@ public void sendDiscoveryRequest(InetSocketAddress target, BiConsumer handler) { + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { this.newConnectionHandler = handler; } @@ -151,6 +153,16 @@ public void sendSignal(InetSocketAddress recipient, long targetNetworkId, String sendPacket(buf, recipient); } + // New sendSignal looking up Address from ID + public void sendSignal(long targetNetworkId, String data) { + InetSocketAddress recipient = peerAddresses.get(targetNetworkId); + if (recipient != null) { + sendSignal(recipient, targetNetworkId, data); + } else { + log.warn("Attempted to send signal to unknown peer: {}", targetNetworkId); + } + } + private void sendPacket(ByteBuf packetData, InetSocketAddress target) { try { byte[] encrypted = NetherNetConstants.encryptDiscoveryPacket(packetData); @@ -181,6 +193,7 @@ protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) th try { int packetId = decrypted.readUnsignedShortLE(); long senderId = decrypted.readLongLE(); + decrypted.skipBytes(8); // Padding if (senderId == this.networkId) { @@ -188,17 +201,19 @@ protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) th return; } + peerAddresses.put(senderId, packet.sender()); + switch (packetId) { - case NetherNetConstants.ID_DISCOVERY_REQUEST: + case NetherNetConstants.ID_DISCOVERY_REQUEST -> { log.trace("Handled discovery request from {}", packet.sender()); handleRequest(senderId, packet.sender()); - break; - case NetherNetConstants.ID_DISCOVERY_MESSAGE: + } + case NetherNetConstants.ID_DISCOVERY_MESSAGE -> { log.trace("Handled discovery message from {}", packet.sender()); log.trace("Message Data: {}", decrypted.toString(StandardCharsets.UTF_8)); handleMessage(decrypted, senderId); - break; - case NetherNetConstants.ID_DISCOVERY_RESPONSE: + } + case NetherNetConstants.ID_DISCOVERY_RESPONSE -> { log.trace("Handled discovery response from {}", packet.sender()); if (discoveryCallback != null) { log.trace("Response Data: {}", decrypted.toString(StandardCharsets.UTF_8)); @@ -206,7 +221,10 @@ protected void channelRead0(ChannelHandlerContext ctx, DatagramPacket packet) th // We retain it because we are passing it out of the pipeline handler discoveryCallback.accept(senderId, decrypted.retain()); } - break; + } + default -> { + log.debug("Received unknown discovery packet ID {} from {}", packetId, packet.sender()); + } } } catch (Exception e) { log.debug("Error processing discovery packet from {}", packet.sender(), e); @@ -229,10 +247,22 @@ private void handleRequest(long senderId, InetSocketAddress sender) { private void handleMessage(ByteBuf data, long senderId) { long recipientId = data.readLongLE(); - if (recipientId != this.networkId) return; + + if (recipientId != this.networkId && recipientId != 0) { + log.trace("Ignoring message intended for {}, but I am {}", recipientId, this.networkId); + return; + } int len = data.readIntLE(); + if (data.readableBytes() < len) { + log.trace("Malformed message: claimed length {} but only has {}", len, data.readableBytes()); + return; + } + String messageData = data.readCharSequence(len, StandardCharsets.UTF_8).toString(); + if ("Ping".equals(messageData)) { + return; + } String[] parts = messageData.split(" ", 3); if (parts.length < 2) return; @@ -242,14 +272,22 @@ private void handleMessage(ByteBuf data, long senderId) { long connectionId = Long.parseUnsignedLong(parts[1]); Consumer handler = signalHandlers.get(connectionId); + if (handler != null) { handler.accept(messageData); - } else if (NetherNetConstants.SIGNAL_CONNECT_REQUEST.equals(type) && newConnectionHandler != null) { - String payload = parts.length > 2 ? parts[2] : ""; - newConnectionHandler.accept(connectionId, payload); + } else if (NetherNetConstants.SIGNAL_CONNECT_REQUEST.equals(type)) { + if (newConnectionHandler != null) { + String payload = parts.length > 2 ? parts[2] : ""; + log.trace("Dispatching New Connection: ID={} Sender={}", Long.toUnsignedString(connectionId), Long.toUnsignedString(senderId)); + newConnectionHandler.onConnect(connectionId, Long.toUnsignedString(senderId), payload); + } else { + log.debug("Received CONNECT_REQUEST but no NewConnectionHandler is set!"); + } + } else { + log.debug("Unhandled signal type: {}", type); } } catch (NumberFormatException e) { - // Invalid format + log.debug("Invalid connection ID format in message: {}", messageData); } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java index b961c20..c19c044 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -13,7 +13,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -public class NetherNetDiscoverySignaling implements NetherNetSignaling { +public class NetherNetDiscoverySignaling implements NetherNetClientSignaling, NetherNetServerSignaling { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscoverySignaling.class); private final NetherNetDiscovery discovery; @@ -55,15 +55,15 @@ public CompletableFuture> connect(SocketAddress remote) { this.remoteAddress = (InetSocketAddress) remote; try { - if (!discovery.isActive()) { + if (!this.discovery.isActive()) { log.info("Binding NetherNet Discovery to {}", bindAddress); - discovery.bind(bindAddress); + this.discovery.bind(bindAddress); } log.debug("Sending Discovery Request to {}", remote); // Send request and register the callback to capture the ID - discovery.sendDiscoveryRequest(this.remoteAddress, (serverNetworkId, payload) -> { + this.discovery.sendDiscoveryRequest(this.remoteAddress, (serverNetworkId, payload) -> { try { log.info("Discovery Response Received! Server NetworkID: {}", serverNetworkId); @@ -87,29 +87,50 @@ public CompletableFuture> connect(SocketAddress remote) { } @Override - public void sendSignal(String targetNetworkId, String data) { - if (remoteAddress == null) { - log.warn("Cannot send signal: Remote address not set (connect() not called?)"); - return; + public void bind(SocketAddress localAddress) { + if (!this.discovery.isActive()) { + if (localAddress instanceof InetSocketAddress) { + this.discovery.bind((InetSocketAddress) localAddress); + } else { + this.discovery.bind(bindAddress); + } } - - // If the Channel passed '0' (unknown), use the one we discovered. + } + + @Override + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.discovery.setNewConnectionHandler(handler); + } + + @Override + public void setAdvertisementData(PongData pongData) { + this.discovery.setPongData(pongData); + } + + @Override + public void sendSignal(String targetNetworkId, String data) { String actualIdStr = targetNetworkId; + + // If '0' is passed, try to use the discovered ID (Client Mode) if (actualIdStr == null || actualIdStr.equals("0")) { actualIdStr = discoveredServerId.get(); } if (actualIdStr == null) { - log.warn("Cannot send signal: Unknown Server Network ID."); + log.warn("Cannot send signal: Unknown Network ID."); return; } - log.trace("Sending Signal to {} (ID: {}): {}", remoteAddress, actualIdStr, data); try { - // LAN protocol strictly requires a Long ID. - // If we are trying to connect to a Realm (String ID) via LAN signaling, this is invalid configuration. long id = Long.parseUnsignedLong(actualIdStr); - discovery.sendSignal(remoteAddress, id, data); + + // If we have an explicit remote address (Client Mode), use it directly + if (remoteAddress != null) { + this.discovery.sendSignal(remoteAddress, id, data); + } else { + // Server Mode: Use the ID to find the address in the Discovery map + this.discovery.sendSignal(id, data); + } } catch (NumberFormatException e) { log.error("Cannot send LAN signal to non-numeric Network ID: {}", actualIdStr); } @@ -117,11 +138,16 @@ public void sendSignal(String targetNetworkId, String data) { @Override public void setSignalHandler(long connectionId, Consumer handler) { - discovery.registerSignalHandler(connectionId, handler); + this.discovery.registerSignalHandler(connectionId, handler); + } + + @Override + public void removeSignalHandler(long connectionId) { + this.discovery.unregisterSignalHandler(connectionId); } @Override public void close() { - discovery.close(); + this.discovery.close(); } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java new file mode 100644 index 0000000..ff1834f --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -0,0 +1,104 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import java.net.SocketAddress; + +public interface NetherNetServerSignaling extends NetherNetSignaling { + /** + * Binds the signaling medium to listen for incoming connections (Server mode). + */ + void bind(SocketAddress localAddress); + + /** + * Handler for new connections. + * @param handler Functional interface receiving (ConnectionID, RemoteNetworkID, Payload) + */ + void setNewConnectionHandler(NewConnectionHandler handler); + + /** + * Sets the advertisement data for the discovery mechanism (e.g. LAN Pong). + */ + void setAdvertisementData(PongData pongData); + + @FunctionalInterface + interface NewConnectionHandler { + void onConnect(long connectionId, String remoteNetworkId, String payload); + } + + /** + * Data structure for Pong advertisement data. + * + * @param serverName The name of the server. + * @param levelName The name of the level/world. + * @param gameType The game type (e.g. Survival, Creative). + * @param playerCount The current number of players. + * @param maxPlayerCount The maximum number of players allowed. + * @param isEditorWorld Whether the world is an editor world. + * @param isHardcore Whether the world is in hardcore mode. + * @param transportLayer The transport layer identifier (e.g. NetherNet). + * @param connectionType The connection type identifier (e.g. LAN, Online). + */ + public record PongData(String serverName, String levelName, int gameType, int playerCount, int maxPlayerCount, + boolean isEditorWorld, boolean isHardcore, int transportLayer, int connectionType) { + public static class Builder { + private String serverName = "Server"; + private String levelName = "World"; + private int gameType = 0; // Default to Survival + private int playerCount = 0; + private int maxPlayerCount = 10; + private boolean isEditorWorld = false; + private boolean isHardcore = false; + private int transportLayer = 2; // Default to NetherNet + private int connectionType = 4; // Default to LAN + + public Builder setServerName(String serverName) { + this.serverName = serverName; + return this; + } + + public Builder setLevelName(String levelName) { + this.levelName = levelName; + return this; + } + + public Builder setGameType(int gameType) { + this.gameType = gameType; + return this; + } + + public Builder setPlayerCount(int playerCount) { + this.playerCount = playerCount; + return this; + } + + public Builder setMaxPlayerCount(int maxPlayerCount) { + this.maxPlayerCount = maxPlayerCount; + return this; + } + + public Builder setIsEditorWorld(boolean isEditorWorld) { + this.isEditorWorld = isEditorWorld; + return this; + } + + public Builder setIsHardcore(boolean isHardcore) { + this.isHardcore = isHardcore; + return this; + } + + public Builder setTransportLayer(int transportLayer) { + this.transportLayer = transportLayer; + return this; + } + + public Builder setConnectionType(int connectionType) { + this.connectionType = connectionType; + return this; + } + + public PongData build() { + return new PongData(serverName, levelName, gameType, playerCount, maxPlayerCount, + isEditorWorld, isHardcore, transportLayer, connectionType); + } + } + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java index e544e73..525805d 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java @@ -1,17 +1,10 @@ package dev.kastle.netty.channel.nethernet.signaling; -import java.net.SocketAddress; import java.util.List; -import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; public interface NetherNetSignaling extends AutoCloseable { - /** - * Connects to the signaling medium. - */ - CompletableFuture> connect(SocketAddress remoteAddress); - /** * Sends a signaling message to the remote peer. * @@ -22,6 +15,8 @@ public interface NetherNetSignaling extends AutoCloseable { void setSignalHandler(long connectionId, Consumer handler); + void removeSignalHandler(long connectionId); + /** * Returns the Local Network ID of this client as a String. * This is required for formatting the 'candidate:' string in SDP. diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index 2d0d151..1dd7121 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -38,7 +38,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; -public class NetherNetXboxSignaling extends SimpleChannelInboundHandler implements NetherNetSignaling { +public class NetherNetXboxSignaling extends SimpleChannelInboundHandler implements NetherNetClientSignaling, NetherNetServerSignaling { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetXboxSignaling.class); private static final Gson gson = new Gson(); @@ -51,6 +51,7 @@ public class NetherNetXboxSignaling extends SimpleChannelInboundHandler> connectFuture; private final Map> handlers = new ConcurrentHashMap<>(); + private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; public NetherNetXboxSignaling(String localNetworkId, String xboxToken) { this.localNetworkId = localNetworkId; @@ -74,7 +75,17 @@ public String getLocalNetworkId() { @Override public synchronized CompletableFuture> connect(SocketAddress remoteAddress) { - // If already connecting or connected, return the existing future + // SocketAddress is ignored for Xbox Signaling Service connection + return connectInternal(); + } + + @Override + public void bind(SocketAddress localAddress) { + // SocketAddress is ignored, we connect to the WS URL derived from NetworkID + connectInternal(); + } + + private synchronized CompletableFuture> connectInternal() { if (connectFuture != null) { return connectFuture; } @@ -109,10 +120,21 @@ protected void initChannel(SocketChannel ch) { return connectFuture; } + @Override + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.newConnectionHandler = handler; + } + + @Override + public void setAdvertisementData(PongData pongData) { + // No-op for Xbox Signaling. + // Advertisement is handled via the Session Directory service (PUT /session/...). + } + @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) { - log.info("NetherNet Signaling WebSocket Connected"); + log.debug("NetherNet Signaling WebSocket Connected"); startPingLoop(ctx); } else { super.userEventTriggered(ctx, evt); @@ -143,6 +165,11 @@ public void setSignalHandler(long connectionId, Consumer handler) { this.handlers.put(connectionId, handler); } + @Override + public void removeSignalHandler(long connectionId) { + this.handlers.remove(connectionId); + } + @Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { String text = frame.text(); @@ -152,24 +179,29 @@ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) int type = json.get("Type").getAsInt(); switch (type) { - case 2: // Credentials + case 2 -> { // Credentials if (json.has("Message") && !connectFuture.isDone()) { connectFuture.complete(parseTurnServers(json.get("Message").getAsString())); } - break; - case 1: // Signal + } + case 1 -> { // Signal + String sender = "0"; + if (json.has("From")) { + sender = json.get("From").getAsString(); + } + if (json.has("Message")) { String rawMsg = json.get("Message").getAsString(); - dispatchSignal(rawMsg); - } - break; + dispatchSignal(sender, rawMsg); + } + } } } catch (Exception e) { log.error("Signaling error", e); } } - private void dispatchSignal(String rawMsg) { + private void dispatchSignal(String sender, String rawMsg) { // Format: TYPE CONNECTION_ID PAYLOAD try { String[] parts = rawMsg.split(" ", 3); @@ -178,6 +210,9 @@ private void dispatchSignal(String rawMsg) { Consumer handler = handlers.get(connectionId); if (handler != null) { handler.accept(rawMsg); + } else if ("CONNECTREQUEST".equals(parts[0]) && newConnectionHandler != null) { + String payload = parts.length > 2 ? parts[2] : ""; + newConnectionHandler.onConnect(connectionId, sender, payload); } } } catch (Exception e) { From 42c12723ee0db63c44f497f85dcdeacbd20cd026 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:18:53 -0800 Subject: [PATCH 08/22] Expose ice servers in NetherNetXboxSignaling Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../nethernet/NetherNetServerChannel.java | 16 ++++++++++++++++ .../signaling/NetherNetServerSignaling.java | 9 +++++++++ .../signaling/NetherNetXboxSignaling.java | 15 +++++++++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index 6f2f451..7e459a3 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -2,6 +2,8 @@ import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetXboxSignaling; import dev.kastle.webrtc.CreateSessionDescriptionObserver; import dev.kastle.webrtc.PeerConnectionFactory; import dev.kastle.webrtc.PeerConnectionObserver; @@ -10,6 +12,7 @@ import dev.kastle.webrtc.RTCConfiguration; import dev.kastle.webrtc.RTCDataChannel; import dev.kastle.webrtc.RTCIceCandidate; +import dev.kastle.webrtc.RTCIceServer; import dev.kastle.webrtc.RTCPeerConnection; import dev.kastle.webrtc.RTCPeerConnectionState; import dev.kastle.webrtc.RTCSdpType; @@ -24,6 +27,7 @@ import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.util.List; public class NetherNetServerChannel extends AbstractServerChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetServerChannel.class); @@ -60,6 +64,18 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe RTCConfiguration rtcConfig = new RTCConfiguration(); rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; + // Inject ICE servers if the signaling implementation supports it + if (this.signaling instanceof NetherNetXboxSignaling xboxSignaling) { + List iceServers = xboxSignaling.getIceServers(); + for (IceServerInfo info : iceServers) { + RTCIceServer iceServer = new RTCIceServer(); + iceServer.urls = info.urls; + iceServer.username = info.username; + iceServer.password = info.password; + rtcConfig.iceServers.add(iceServer); + } + } + ServerPeerConnectionObserver observer = new ServerPeerConnectionObserver(connectionId, remoteNetworkId); RTCPeerConnection pc = factory.createPeerConnection(rtcConfig, observer); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java index ff1834f..6a8b10d 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -1,6 +1,7 @@ package dev.kastle.netty.channel.nethernet.signaling; import java.net.SocketAddress; +import java.util.List; public interface NetherNetServerSignaling extends NetherNetSignaling { /** @@ -24,6 +25,14 @@ interface NewConnectionHandler { void onConnect(long connectionId, String remoteNetworkId, String payload); } + /** + * Returns the ICE servers (STUN/TURN) obtained from the signaling handshake. + * Returns empty list if none available or not applicable. + */ + default List getIceServers() { + return java.util.Collections.emptyList(); + } + /** * Data structure for Pong advertisement data. * diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index 1dd7121..a979a09 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -32,6 +32,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; @@ -53,6 +54,8 @@ public class NetherNetXboxSignaling extends SimpleChannelInboundHandler> handlers = new ConcurrentHashMap<>(); private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; + private volatile List iceServers = new ArrayList<>(); + public NetherNetXboxSignaling(String localNetworkId, String xboxToken) { this.localNetworkId = localNetworkId; this.xboxToken = xboxToken; @@ -65,7 +68,7 @@ public NetherNetXboxSignaling(long localNetworkId, String xboxToken) { } public NetherNetXboxSignaling(String xboxToken) { - this(Long.toUnsignedString(ThreadLocalRandom.current().nextLong()), xboxToken); + this(Long.toUnsignedString(ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE)), xboxToken); } @Override @@ -91,12 +94,16 @@ private synchronized CompletableFuture> connectInternal() { } connectFuture = new CompletableFuture<>(); + connectFuture.thenAccept(servers -> this.iceServers = servers); try { SslContext sslCtx = SslContextBuilder.forClient().build(); WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory.newHandshaker( uri, WebSocketVersion.V13, null, false, - new DefaultHttpHeaders().add("Authorization", xboxToken) + new DefaultHttpHeaders() + .add("Authorization", xboxToken) + .add("Session-Id", UUID.randomUUID().toString()) + .add("Request-Id", UUID.randomUUID().toString()) ); Bootstrap b = new Bootstrap(); @@ -120,6 +127,10 @@ protected void initChannel(SocketChannel ch) { return connectFuture; } + public List getIceServers() { + return this.iceServers; + } + @Override public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { this.newConnectionHandler = handler; From 3a049c8eb79e0c5d66728cbcb76e608881f7c5ba Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:06:55 -0800 Subject: [PATCH 09/22] Remove native depends Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- transport-nethernet/build.gradle.kts | 16 ---------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7be3bc3..9d66130 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] netty = "4.1.130.Final" junit = "5.14.1" -webrtc-java = "1.0.2" +webrtc-java = "1.0.3" gson = "2.13.2" [libraries] diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts index 6c34384..f083848 100644 --- a/transport-nethernet/build.gradle.kts +++ b/transport-nethernet/build.gradle.kts @@ -1,14 +1,5 @@ description = "NetherNet transport for Netty" -val nativePlatforms = listOf( - "windows-x86_64", - "windows-aarch64", - "linux-x86_64", - "linux-aarch64", - "macos-x86_64", - "macos-aarch64" -) - dependencies { api(libs.bundles.netty) api(libs.netty.codec.http) @@ -16,13 +7,6 @@ dependencies { api(libs.webrtc.java) implementation(libs.gson) - nativePlatforms.forEach { platform -> - implementation(libs.webrtc.java) { - artifact { - classifier = platform - } - } - } testImplementation(libs.bundles.junit) testRuntimeOnly(libs.junit.platform.launcher) From b7e1579ea5cc3f53c830e33c82b68b005cf225ca Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:26:35 -0800 Subject: [PATCH 10/22] More robust logging and ICE/TURN parsing Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../nethernet/NetherNetServerChannel.java | 55 +++++++++++++++---- .../signaling/NetherNetXboxSignaling.java | 39 +++++++++++-- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index 7e459a3..b32e62e 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -38,6 +38,7 @@ public class NetherNetServerChannel extends AbstractServerChannel { private final NetherNetServerSignaling signaling; private InetSocketAddress localAddress; + private volatile boolean open = true; public NetherNetServerChannel(NetherNetServerSignaling signaling) { this(new PeerConnectionFactory(), signaling); @@ -65,14 +66,22 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; // Inject ICE servers if the signaling implementation supports it - if (this.signaling instanceof NetherNetXboxSignaling xboxSignaling) { + if (this.signaling instanceof NetherNetXboxSignaling) { + NetherNetXboxSignaling xboxSignaling = (NetherNetXboxSignaling) this.signaling; List iceServers = xboxSignaling.getIceServers(); - for (IceServerInfo info : iceServers) { - RTCIceServer iceServer = new RTCIceServer(); - iceServer.urls = info.urls; - iceServer.username = info.username; - iceServer.password = info.password; - rtcConfig.iceServers.add(iceServer); + + if (iceServers != null && !iceServers.isEmpty()) { + log.trace("Injecting {} ICE Servers into PeerConnection for {}", iceServers.size(), Long.toUnsignedString(connectionId)); + for (IceServerInfo info : iceServers) { + RTCIceServer iceServer = new RTCIceServer(); + iceServer.urls = info.urls; + iceServer.username = info.username; + iceServer.password = info.password; + rtcConfig.iceServers.add(iceServer); + log.trace(" - Added ICE Server: {} (User: {})", info.urls, info.username); + } + } else { + log.warn("NetherNetXboxSignaling has NO ICE servers available! WAN connections will likely fail."); } } @@ -90,10 +99,14 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe String data = parts[2]; switch (type) { - case NetherNetConstants.SIGNAL_CANDIDATE_ADD -> + case NetherNetConstants.SIGNAL_CANDIDATE_ADD -> { + log.trace("Applying Remote Candidate for {}: {}", Long.toUnsignedString(connectionId), data); pc.addIceCandidate(new RTCIceCandidate("0", 0, data)); - case NetherNetConstants.SIGNAL_CONNECT_ERROR -> + } + case NetherNetConstants.SIGNAL_CONNECT_ERROR -> { + log.debug("Received CONNECT_ERROR for {}", Long.toUnsignedString(connectionId)); child.close(); + } } }); @@ -101,12 +114,14 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe pc.setRemoteDescription(new RTCSessionDescription(RTCSdpType.OFFER, offerSdp), new SetSessionDescriptionObserver() { @Override public void onSuccess() { + log.trace("Remote description set for {}", Long.toUnsignedString(connectionId)); pc.createAnswer(new RTCAnswerOptions(), new CreateSessionDescriptionObserver() { @Override public void onSuccess(RTCSessionDescription description) { pc.setLocalDescription(description, new SetSessionDescriptionObserver() { @Override public void onSuccess() { + log.trace("Sending Answer SDP for {}", Long.toUnsignedString(connectionId)); signaling.sendSignal( remoteNetworkId, NetherNetConstants.buildSignalConnectResponse(connectionId, description.sdp) @@ -146,12 +161,23 @@ public void setChildChannel(NetherNetChildChannel child) { @Override public void onIceCandidate(RTCIceCandidate candidate) { + if (log.isTraceEnabled()) { + log.trace("Generated ICE Candidate for {}: {} (Type: {})", + Long.toUnsignedString(this.connectionId), candidate.sdp, extractCandidateType(candidate.sdp)); + } signaling.sendSignal( remoteNetworkId, NetherNetConstants.buildSignalCandidateAdd(connectionId, candidate.sdp) ); } + private String extractCandidateType(String sdp) { + if (sdp.contains(" typ host ")) return "host"; + if (sdp.contains(" typ srflx ")) return "srflx"; + if (sdp.contains(" typ relay ")) return "relay"; + return "unknown"; + } + @Override public void onConnectionChange(RTCPeerConnectionState state) { log.debug("Connection {} state changed: {}", Long.toUnsignedString(this.connectionId), state); @@ -185,8 +211,13 @@ private void checkDataChannels() { @Override protected void doClose() throws Exception { - signaling.close(); - factory.dispose(); + open = false; + + try { + signaling.close(); + } finally { + factory.dispose(); + } } @Override @@ -214,7 +245,7 @@ public boolean isOpen() { @Override public boolean isActive() { - return isOpen(); + return isOpen() && localAddress0() != null; } @Override diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index a979a09..c9b4408 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -122,6 +122,7 @@ protected void initChannel(SocketChannel ch) { this.channel = b.connect(uri.getHost(), 443).sync().channel(); } catch (Exception e) { + log.error("Failed to connect to signaling service", e); connectFuture.completeExceptionally(e); } return connectFuture; @@ -168,6 +169,8 @@ public void sendSignal(String targetNetworkId, String data) { msg.addProperty("To", targetNetworkId); msg.addProperty("Message", data); channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(msg))); + } else { + log.debug("Attempted to send signal to {} but WebSocket is closed or null!", targetNetworkId); } } @@ -208,7 +211,7 @@ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) } } } catch (Exception e) { - log.error("Signaling error", e); + log.error("Signaling error processing frame: " + text, e); } } @@ -235,23 +238,49 @@ private List parseTurnServers(String jsonString) { List result = new ArrayList<>(); try { JsonObject root = gson.fromJson(jsonString, JsonObject.class); + + JsonArray servers = null; if (root.has("TurnAuthServers")) { - JsonArray servers = root.getAsJsonArray("TurnAuthServers"); + servers = root.getAsJsonArray("TurnAuthServers"); + } else if (root.has("turnAuthServers")) { + servers = root.getAsJsonArray("turnAuthServers"); + } + + if (servers != null) { for (JsonElement el : servers) { JsonObject server = el.getAsJsonObject(); + List urls = new ArrayList<>(); + + JsonArray urlsArray = null; if (server.has("Urls")) { - List urls = new ArrayList<>(); - server.getAsJsonArray("Urls").forEach(u -> urls.add(u.getAsString())); + urlsArray = server.getAsJsonArray("Urls"); + } else if (server.has("urls")) { + urlsArray = server.getAsJsonArray("urls"); + } + + if (urlsArray != null) { + urlsArray.forEach(u -> urls.add(u.getAsString())); IceServerInfo info = new IceServerInfo(); info.urls = urls; + if (server.has("Username")) info.username = server.get("Username").getAsString(); + else if (server.has("username")) info.username = server.get("username").getAsString(); + if (server.has("Password")) info.password = server.get("Password").getAsString(); + else if (server.has("password")) info.password = server.get("password").getAsString(); + else if (server.has("Credential")) info.password = server.get("Credential").getAsString(); + else if (server.has("credential")) info.password = server.get("credential").getAsString(); + result.add(info); } } } - } catch (Exception ignored) {} + } catch (Exception e) { + log.error("Failed to parse TURN servers", e); + } + + log.debug("Successfully parsed " + result.size() + " ICE servers."); return result; } From 1ae72d4ba12a0f1773094bd82fe6f2694b1c1249 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:31:14 -0800 Subject: [PATCH 11/22] Properly track NetherNetServerChannel state Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../netty/channel/nethernet/NetherNetServerChannel.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index b32e62e..bb83d57 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -211,7 +211,7 @@ private void checkDataChannels() { @Override protected void doClose() throws Exception { - open = false; + this.open = false; try { signaling.close(); @@ -240,7 +240,7 @@ protected boolean isCompatible(EventLoop loop) { @Override public boolean isOpen() { - return true; + return this.open; } @Override From c29af6230b129719634420bc7a96df8977eac51a Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 3 Jan 2026 21:53:33 -0800 Subject: [PATCH 12/22] Prep for nethernet release Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .github/workflows/publish.yml | 6 +- build.gradle.kts | 22 ++++---- gradle/libs.versions.toml | 4 +- transport-nethernet/build.gradle.kts | 4 ++ .../channel/nethernet/NetherNetChannel.java | 6 +- .../nethernet/NetherNetChannelFactory.java | 14 +++++ .../nethernet/NetherNetClientChannel.java | 30 ++++++---- .../channel/nethernet/NetherNetConstants.java | 35 ++++++++++++ .../nethernet/NetherNetServerChannel.java | 56 +++++++++++++++++-- ...g.java => DefaultNetherChannelConfig.java} | 4 +- .../DefaultNetherClientChannelConfig.java | 47 ++++++++++++++++ .../DefaultNetherServerChannelConfig.java | 47 ++++++++++++++++ .../nethernet/config/NetherChannelOption.java | 23 ++++++++ .../nethernet/config/NetherNetAddress.java | 23 +++++++- .../signaling/NetherNetDiscovery.java | 5 ++ .../NetherNetDiscoverySignaling.java | 12 ++++ .../signaling/NetherNetServerSignaling.java | 12 ++++ .../signaling/NetherNetSignaling.java | 50 +++++++++++++++-- .../signaling/NetherNetXboxSignaling.java | 30 ++++++---- .../util/nethernet/NetherNetScanner.java | 2 +- transport-raknet/build.gradle.kts | 4 ++ 21 files changed, 383 insertions(+), 53 deletions(-) rename transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/{NetherNetChannelConfig.java => DefaultNetherChannelConfig.java} (86%) create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java create mode 100644 transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 354861c..83f6422 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: Kas-tle/NetworkCompatible/.github/setup-gradle-composite@master - name: Publish - run: ./gradlew publishAggregatedPublicationToCentralPortal + run: ./gradlew publishAggregationToCentralPortal env: MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} @@ -28,7 +28,7 @@ jobs: - name: Make Release Publication if: ${{ success() && github.repository == 'Kas-tle/NetworkCompatible' && github.ref_name == 'master' }} id: release - uses: Kas-tle/base-release-action@65d06f835be34757c6d73c16959c97e92c2a3c7f + uses: Kas-tle/base-release-action@b89ab10da9dfaa0b5fb0ca9e77679c7c7d1297f8 with: files: | transport-raknet/build/libs/*.jar @@ -42,7 +42,7 @@ jobs: releaseName: ${{ steps.version.outputs.version }} releaseBodyDependencyUsage: 'java' releaseBodyDependencyJavaGroupId: 'dev.kastle.netty' - releaseBodyDependencyJavaArtifactId: 'netty-transport-raknet' + releaseBodyDependencyJavaArtifactId: 'netty-transport-(raknet|nethernet)' upload-logs: name: Upload Logs diff --git a/build.gradle.kts b/build.gradle.kts index 7cae5d8..b15b2f7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ */ plugins { - alias(libs.plugins.nmcp) + alias(libs.plugins.nmcp.aggregation) `maven-publish` } @@ -102,21 +102,23 @@ subprojects { useJUnitPlatform() } } +} - nmcp { - publishAllPublications {} +dependencies { + allprojects { + nmcpAggregation(project(path)) } } -nmcp { - publishAggregation { +nmcpAggregation { + centralPortal { project(":transport-raknet") - // TODO: Add publishing for transport-nethetnet - - username.set(System.getenv("MAVEN_CENTRAL_USERNAME") ?: "username") - password.set(System.getenv("MAVEN_CENTRAL_PASSWORD") ?: "password") + project(":transport-nethernet") - publicationType.set("AUTOMATIC") + username.set(System.getenv("MAVEN_CENTRAL_USERNAME")) + password.set(System.getenv("MAVEN_CENTRAL_PASSWORD")) + + publishingType.set("AUTOMATIC") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d66130..fa1d205 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ netty = "4.1.130.Final" junit = "5.14.1" webrtc-java = "1.0.3" gson = "2.13.2" +nmcp = "1.4.0" [libraries] expiringmap = { group = "net.jodah", name = "expiringmap", version = "0.5.10" } @@ -28,4 +29,5 @@ junit = [ "junit-jupiter-engine", "junit-jupiter-api", "junit-jupiter-params" ] [plugins] -nmcp = { id = "com.gradleup.nmcp", version = "0.0.9" } +nmcp = { id = "com.gradleup.nmcp", version.ref = "nmcp" } +nmcp-aggregation = { id = "com.gradleup.nmcp.aggregation", version.ref = "nmcp" } diff --git a/transport-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts index f083848..76eef36 100644 --- a/transport-nethernet/build.gradle.kts +++ b/transport-nethernet/build.gradle.kts @@ -1,3 +1,7 @@ +plugins { + id("com.gradleup.nmcp") +} + description = "NetherNet transport for Netty" dependencies { diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java index 1610bd7..6dfd05a 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -1,6 +1,6 @@ package dev.kastle.netty.channel.nethernet; -import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; +import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; import dev.kastle.webrtc.RTCDataChannel; import dev.kastle.webrtc.RTCDataChannelBuffer; import dev.kastle.webrtc.RTCDataChannelObserver; @@ -27,7 +27,7 @@ public abstract class NetherNetChannel extends AbstractChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetChannel.class); protected static final ChannelMetadata METADATA = new ChannelMetadata(false); - protected final NetherNetChannelConfig config; + protected DefaultNetherChannelConfig config; protected volatile RTCPeerConnection peerConnection; protected volatile SocketAddress remoteAddress; protected volatile SocketAddress localAddress; @@ -43,7 +43,7 @@ protected NetherNetChannel(Channel parent, InetSocketAddress remote, InetSocketA super(parent); this.remoteAddress = remote; this.localAddress = local; - this.config = new NetherNetChannelConfig(this); + this.config = new DefaultNetherChannelConfig(this); } public void setDataChannels(RTCDataChannel reliable, RTCDataChannel unreliable) { diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java index e5494a4..82c3cc0 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -21,10 +21,24 @@ public T newChannel() { return channelCreator.get(); } + /** + * Creates a NetherNet Server Channel Factory. + * + * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * @param signaling The NetherNetServerSignaling instance for signaling. + * @return A ChannelFactory for NetherNetServerChannel. + */ public static ChannelFactory server(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { return new NetherNetChannelFactory<>(() -> new NetherNetServerChannel(factory, signaling)); } + /** + * Creates a NetherNet Client Channel Factory. + * + * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * @param signaling The NetherNetClientSignaling instance for signaling. + * @return A ChannelFactory for NetherNetClientChannel. + */ public static ChannelFactory client(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { return new NetherNetChannelFactory<>(() -> new NetherNetClientChannel(factory, signaling)); } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index 3d29a56..3c3c577 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -1,5 +1,7 @@ package dev.kastle.netty.channel.nethernet; +import dev.kastle.netty.channel.nethernet.config.DefaultNetherClientChannelConfig; +import dev.kastle.netty.channel.nethernet.config.NetherChannelOption; import dev.kastle.netty.channel.nethernet.config.NetherNetAddress; import dev.kastle.netty.channel.nethernet.signaling.NetherNetClientSignaling; import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling; @@ -46,20 +48,31 @@ public class NetherNetClientChannel extends NetherNetChannel { private ChannelPromise connectPromise; - private static final int HANDSHAKE_TIMEOUT_MS = 3000; private volatile ScheduledFuture handshakeTimeoutTask; private volatile String localUfrag; + /** + * Creates a NetherNetClientChannel with a new PeerConnectionFactory. + * + * @param signaling The NetherNetClientSignaling instance for signaling. + */ public NetherNetClientChannel(NetherNetClientSignaling signaling) { this(new PeerConnectionFactory(), signaling); } + /** + * Creates a NetherNetClientChannel. + * + * @param factory The PeerConnectionFactory to use. Should be reused where possible. + * @param signaling The NetherNetClientSignaling instance for signaling. + */ public NetherNetClientChannel(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { super(null, null, null); this.factory = factory; this.signaling = signaling; this.connectionId = this.cycleConnectionId(); + this.config = new DefaultNetherClientChannelConfig(this); } public void setTargetNetworkId(String id) { @@ -119,12 +132,14 @@ private void startHandshake() { log.debug("Starting Handshake with Connection ID: {}", Long.toUnsignedString(this.connectionId)); if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + + int handshakeTimeout = this.config().getOption(NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS); handshakeTimeoutTask = eventLoop().schedule(() -> { if (!handshakeComplete) { log.debug("Handshake timed out. Resetting and Retrying..."); resetAndRetryHandshake(); } - }, HANDSHAKE_TIMEOUT_MS, TimeUnit.MILLISECONDS); + }, handshakeTimeout, TimeUnit.MILLISECONDS); signaling.setSignalHandler(this.connectionId, this::handleSignal); @@ -155,13 +170,8 @@ private void resetAndRetryHandshake() { peerConnection = null; } - // Remove handler for the failed connection ID signaling.removeSignalHandler(this.connectionId); - - // Generate new ID for the new attempt this.cycleConnectionId(); - - // Restart flow startHandshake(); } @@ -172,9 +182,9 @@ private void initWebRTC(List iceServers) { if (iceServers != null) { for (NetherNetSignaling.IceServerInfo info : iceServers) { RTCIceServer iceServer = new RTCIceServer(); - iceServer.urls = info.urls; - iceServer.username = info.username; - iceServer.password = info.password; + iceServer.urls = info.urls(); + iceServer.username = info.username(); + iceServer.password = info.password(); rtcConfig.iceServers.add(iceServer); } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java index 8a3caa6..4b47bcd 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -49,6 +49,13 @@ public class NetherNetConstants { } } + /** + * Encrypts a discovery packet using AES encryption and HMAC-SHA256 for integrity. + * + * @param packet The ByteBuf containing the discovery packet to encrypt. + * @return The encrypted byte array ready for transmission. + * @throws Exception if encryption fails. + */ public static byte[] encryptDiscoveryPacket(ByteBuf packet) throws Exception { int len = packet.readableBytes() + 2; ByteBuf payload = Unpooled.buffer(len); @@ -79,6 +86,13 @@ public static byte[] encryptDiscoveryPacket(ByteBuf packet) throws Exception { return out; } + /** + * Decrypts a discovery packet and verifies its integrity. + * + * @param input The ByteBuf containing the received discovery packet. + * @return A ByteBuf with the decrypted payload, or null if verification fails. + * @throws Exception if decryption fails. + */ public static ByteBuf decryptDiscoveryPacket(ByteBuf input) throws Exception { if (input.readableBytes() < 32) { log.debug("Discovery packet too short to contain valid signature"); @@ -112,14 +126,35 @@ public static ByteBuf decryptDiscoveryPacket(ByteBuf input) throws Exception { return payload; } + /** + * Builds a signaling message for a CONNECTREQUEST. + * + * @param connectionId The unique connection ID. + * @param sdp The SDP payload. + * @return The formatted signaling message. + */ public static String buildSignalConnectRequest(long connectionId, String sdp) { return SIGNAL_CONNECT_REQUEST + " " + Long.toUnsignedString(connectionId) + " " + sdp; } + /** + * Builds a signaling message for a CONNECTRESPONSE. + * + * @param connectionId The unique connection ID. + * @param sdp The SDP payload. + * @return The formatted signaling message. + */ public static String buildSignalConnectResponse(long connectionId, String sdp) { return SIGNAL_CONNECT_RESPONSE + " " + Long.toUnsignedString(connectionId) + " " + sdp; } + /** + * Builds a signaling message for a CANDIDATEADD. + * + * @param connectionId The unique connection ID. + * @param candidateSdp The candidate SDP string. + * @return The formatted signaling message. + */ public static String buildSignalCandidateAdd(long connectionId, String candidateSdp) { return SIGNAL_CANDIDATE_ADD + " " + Long.toUnsignedString(connectionId) + " " + candidateSdp; } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index bb83d57..d542d2e 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -1,6 +1,7 @@ package dev.kastle.netty.channel.nethernet; -import dev.kastle.netty.channel.nethernet.config.NetherNetChannelConfig; +import dev.kastle.netty.channel.nethernet.config.DefaultNetherServerChannelConfig; +import dev.kastle.netty.channel.nethernet.config.NetherChannelOption; import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo; import dev.kastle.netty.channel.nethernet.signaling.NetherNetXboxSignaling; @@ -22,31 +23,45 @@ import io.netty.channel.ChannelConfig; import io.netty.channel.ChannelMetadata; import io.netty.channel.EventLoop; +import io.netty.util.concurrent.ScheduledFuture; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.util.List; +import java.util.concurrent.TimeUnit; public class NetherNetServerChannel extends AbstractServerChannel { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetServerChannel.class); private static final ChannelMetadata METADATA = new ChannelMetadata(false, 16); - private final NetherNetChannelConfig config = new NetherNetChannelConfig(this); + private final DefaultNetherServerChannelConfig config; private final PeerConnectionFactory factory; private final NetherNetServerSignaling signaling; private InetSocketAddress localAddress; private volatile boolean open = true; + /** + * Creates a NetherNetServerChannel with a new PeerConnectionFactory. + * + * @param signaling The NetherNetServerSignaling instance for signaling. + */ public NetherNetServerChannel(NetherNetServerSignaling signaling) { this(new PeerConnectionFactory(), signaling); } + /** + * Creates a NetherNetServerChannel. + * + * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * @param signaling The NetherNetServerSignaling instance for signaling. + */ public NetherNetServerChannel(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { this.factory = factory; this.signaling = signaling; + this.config = new DefaultNetherServerChannelConfig(this); } @Override @@ -74,11 +89,11 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe log.trace("Injecting {} ICE Servers into PeerConnection for {}", iceServers.size(), Long.toUnsignedString(connectionId)); for (IceServerInfo info : iceServers) { RTCIceServer iceServer = new RTCIceServer(); - iceServer.urls = info.urls; - iceServer.username = info.username; - iceServer.password = info.password; + iceServer.urls = info.urls(); + iceServer.username = info.username(); + iceServer.password = info.password(); rtcConfig.iceServers.add(iceServer); - log.trace(" - Added ICE Server: {} (User: {})", info.urls, info.username); + log.trace(" - Added ICE Server: {} (User: {})", info.urls(), info.username()); } } else { log.warn("NetherNetXboxSignaling has NO ICE servers available! WAN connections will likely fail."); @@ -90,6 +105,16 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe NetherNetChildChannel child = new NetherNetChildChannel(this, pc, new InetSocketAddress(0), localAddress); observer.setChildChannel(child); + + int handshakeTimeoutSeconds = this.config.getOption(NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS); + ScheduledFuture timeoutTask = eventLoop().schedule(() -> { + if (!child.isActive()) { + log.warn("Connection {} timed out during handshake ({}s)", Long.toUnsignedString(connectionId), handshakeTimeoutSeconds); + child.close(); + pc.close(); + } + }, handshakeTimeoutSeconds, TimeUnit.SECONDS); + observer.setHandshakeTimeout(timeoutTask); // Register Signal Handler signaling.setSignalHandler(connectionId, (signal) -> { @@ -149,11 +174,17 @@ private class ServerPeerConnectionObserver implements PeerConnectionObserver { private RTCDataChannel reliable; private RTCDataChannel unreliable; + private ScheduledFuture handshakeTimeout; + public ServerPeerConnectionObserver(long connectionId, String remoteNetworkId) { this.connectionId = connectionId; this.remoteNetworkId = remoteNetworkId; } + public void setHandshakeTimeout(ScheduledFuture handshakeTimeout) { + this.handshakeTimeout = handshakeTimeout; + } + public void setChildChannel(NetherNetChildChannel child) { this.child = child; checkDataChannels(); @@ -181,6 +212,15 @@ private String extractCandidateType(String sdp) { @Override public void onConnectionChange(RTCPeerConnectionState state) { log.debug("Connection {} state changed: {}", Long.toUnsignedString(this.connectionId), state); + if (state == RTCPeerConnectionState.FAILED || state == RTCPeerConnectionState.CLOSED) { + if (child != null && child.isOpen()) { + log.debug("Closing connection {} due to state change: {}", Long.toUnsignedString(this.connectionId), state); + child.close(); + } + if (handshakeTimeout != null) { + handshakeTimeout.cancel(false); + } + } } @Override @@ -199,6 +239,10 @@ public void onDataChannel(RTCDataChannel dataChannel) { private void checkDataChannels() { if (child != null && reliable != null && unreliable != null) { + if (handshakeTimeout != null) { + handshakeTimeout.cancel(false); + } + log.debug("Data Channels established for {}", Long.toUnsignedString(this.connectionId)); child.setDataChannels(reliable, unreliable); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java similarity index 86% rename from transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetChannelConfig.java rename to transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java index 202c8ab..d56b324 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetChannelConfig.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java @@ -7,10 +7,10 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -public class NetherNetChannelConfig extends DefaultChannelConfig { +public class DefaultNetherChannelConfig extends DefaultChannelConfig { private final Map, Object> options = new ConcurrentHashMap<>(); - public NetherNetChannelConfig(Channel channel) { + public DefaultNetherChannelConfig(Channel channel) { super(channel); } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java new file mode 100644 index 0000000..ce300a3 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java @@ -0,0 +1,47 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; + +import java.util.Map; + +public class DefaultNetherClientChannelConfig extends DefaultNetherChannelConfig { + private volatile int clientHandshakeTimeoutMs = 1000; + + public DefaultNetherClientChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS + ); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + if (option == NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS) { + return (T) Integer.valueOf(this.clientHandshakeTimeoutMs); + } + + return this.channel.parent().config().getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + this.validate(option, value); + + if (option == NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS) { + this.setClientHandshakeTimeoutMs((Integer) value); + return true; + } else { + return this.channel.parent().config().setOption(option, value); + } + } + + void setClientHandshakeTimeoutMs(int clientHandshakeTimeoutMs) { + this.clientHandshakeTimeoutMs = clientHandshakeTimeoutMs; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java new file mode 100644 index 0000000..03cafc9 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java @@ -0,0 +1,47 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelOption; + +import java.util.Map; + +public class DefaultNetherServerChannelConfig extends DefaultNetherChannelConfig { + private volatile int serverRtcHandshakeTimeoutSeconds = 30; + + public DefaultNetherServerChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS + ); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + if (option == NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS) { + return (T) Integer.valueOf(this.serverRtcHandshakeTimeoutSeconds); + } + + return this.channel.parent().config().getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + this.validate(option, value); + + if (option == NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS) { + this.setServerRtcHandshakeTimeoutSeconds((Integer) value); + return true; + } else { + return this.channel.parent().config().setOption(option, value); + } + } + + void setServerRtcHandshakeTimeoutSeconds(int serverRtcHandshakeTimeoutSeconds) { + this.serverRtcHandshakeTimeoutSeconds = serverRtcHandshakeTimeoutSeconds; + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java new file mode 100644 index 0000000..14e5283 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java @@ -0,0 +1,23 @@ +package dev.kastle.netty.channel.nethernet.config; + +import io.netty.channel.ChannelOption; + +public class NetherChannelOption extends ChannelOption { + + /** + * The timeout in seconds for completing the WebRTC handshake on the client before retrying. + */ + public static final ChannelOption NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS = + valueOf(NetherChannelOption.class, "NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS"); + + /** + * The timeout in seconds for completing the WebRTC handshake on the server side before automatically closing the connection. + */ + public static final ChannelOption NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS = + valueOf(NetherChannelOption.class, "NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS"); + + @SuppressWarnings("deprecation") + protected NetherChannelOption(String name) { + super(name); + } +} diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java index f0385d1..a64fb9c 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java @@ -5,20 +5,36 @@ public class NetherNetAddress extends SocketAddress { private final String networkId; + /** + * Creates a NetherNetAddress from a numeric Network ID. + * + * @param networkId The numeric Network ID. + */ public NetherNetAddress(long networkId) { this.networkId = Long.toUnsignedString(networkId); } + /** + * Creates a NetherNetAddress from a string Network ID. + * + * @param networkId The string Network ID. + */ public NetherNetAddress(String networkId) { this.networkId = networkId; } + /** + * Gets the Network ID as a String. + * + * @return the Network ID + */ public String getNetworkId() { return networkId; } /** * Tries to parse the Network ID as a long. + * * @return the long value * @throws NumberFormatException if the ID is not a valid unsigned long string (e.g. Realms ID). */ @@ -26,8 +42,13 @@ public long getNetworkIdAsLong() { return Long.parseUnsignedLong(networkId); } + /** + * Returns the string representation of the Network ID. + * + * @return the Network ID as a string + */ @Override public String toString() { - return "NetherNetAddress(" + networkId + ")"; + return networkId; } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java index 53a43d1..6c24166 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java @@ -37,6 +37,11 @@ public class NetherNetDiscovery extends SimpleChannelInboundHandler discoveryCallback; + /** + * Creates a NetherNetDiscovery instance with the specified Network ID. + * + * @param networkId The Network ID to use for discovery. + */ public NetherNetDiscovery(long networkId) { this.networkId = networkId; } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java index c19c044..b5ef2ef 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -24,14 +24,26 @@ public class NetherNetDiscoverySignaling implements NetherNetClientSignaling, Ne private volatile InetSocketAddress remoteAddress; private final AtomicReference discoveredServerId = new AtomicReference<>(null); + /** + * Creates a NetherNetDiscoverySignaling with a random local Network ID and binds to an ephemeral port. * + */ public NetherNetDiscoverySignaling() { this(ThreadLocalRandom.current().nextLong(), new InetSocketAddress(0)); } + /** + * Creates a NetherNetDiscoverySignaling with the specified local Network ID. + * @param localNetworkId The local Network ID to use. + */ public NetherNetDiscoverySignaling(long localNetworkId) { this(localNetworkId, new InetSocketAddress(0)); } + /** + * Creates a NetherNetDiscoverySignaling with the specified local Network ID and bind address. + * @param localNetworkId The local Network ID to use. + * @param bindAddress The address to bind the discovery socket to. + */ public NetherNetDiscoverySignaling(long localNetworkId, InetSocketAddress bindAddress) { this.localNetworkId = Long.toUnsignedString(localNetworkId); this.discovery = new NetherNetDiscovery(localNetworkId); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java index 6a8b10d..a3085a0 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -17,11 +17,23 @@ public interface NetherNetServerSignaling extends NetherNetSignaling { /** * Sets the advertisement data for the discovery mechanism (e.g. LAN Pong). + * + * @param pongData The Pong advertisement data. */ void setAdvertisementData(PongData pongData); + /** + * Functional interface for new connection handling. + */ @FunctionalInterface interface NewConnectionHandler { + /** + * Called when a new connection is initiated by a remote peer. + * + * @param connectionId The unique connection ID for this session. + * @param remoteNetworkId The Network ID of the remote peer. + * @param payload The initial signaling payload from the remote peer. + */ void onConnect(long connectionId, String remoteNetworkId, String payload); } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java index 525805d..ddd1657 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java @@ -13,8 +13,19 @@ public interface NetherNetSignaling extends AutoCloseable { */ void sendSignal(String targetNetworkId, String data); + /** + * Sets a handler to receive signaling messages for a specific connection ID. + * + * @param connectionId The connection ID to listen for. + * @param handler The handler to process incoming signaling messages. + */ void setSignalHandler(long connectionId, Consumer handler); + /** + * Removes the signaling handler for a specific connection ID. + * + * @param connectionId The connection ID whose handler should be removed. + */ void removeSignalHandler(long connectionId); /** @@ -23,12 +34,43 @@ public interface NetherNetSignaling extends AutoCloseable { */ String getLocalNetworkId(); + /** + * Closes the signaling channel and releases any associated resources. + */ @Override void close(); - class IceServerInfo { - public String username; - public String password; - public List urls; + /** + * Data structure for ICE server information. + * + * @param username The username for the ICE server (if applicable). + * @param password The password for the ICE server (if applicable). + * @param urls The list of URLs for the ICE server. + */ + public record IceServerInfo(String username, String password, List urls) { + public static class Builder { + private String username = ""; + private String password = ""; + private List urls = List.of(); + + public Builder setUsername(String username) { + this.username = username; + return this; + } + + public Builder setPassword(String password) { + this.password = password; + return this; + } + + public Builder setUrls(List urls) { + this.urls = urls; + return this; + } + + public IceServerInfo build() { + return new IceServerInfo(username, password, urls); + } + } } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index c9b4408..1e7a1af 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -56,10 +56,16 @@ public class NetherNetXboxSignaling extends SimpleChannelInboundHandler iceServers = new ArrayList<>(); - public NetherNetXboxSignaling(String localNetworkId, String xboxToken) { - this.localNetworkId = localNetworkId; + /** + * Creates a NetherNetXboxSignaling instance. + * + * @param networkId The Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ + public NetherNetXboxSignaling(String networkId, String xboxToken) { + this.localNetworkId = networkId; this.xboxToken = xboxToken; - this.uri = URI.create("wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling/" + localNetworkId); + this.uri = URI.create("wss://signal.franchise.minecraft-services.net/ws/v1.0/signaling/" + networkId); this.eventLoopGroup = new NioEventLoopGroup(1); } @@ -261,18 +267,18 @@ private List parseTurnServers(String jsonString) { if (urlsArray != null) { urlsArray.forEach(u -> urls.add(u.getAsString())); - IceServerInfo info = new IceServerInfo(); - info.urls = urls; + IceServerInfo.Builder info = new IceServerInfo.Builder(); + info.setUrls(urls); - if (server.has("Username")) info.username = server.get("Username").getAsString(); - else if (server.has("username")) info.username = server.get("username").getAsString(); + if (server.has("Username")) info.setUsername(server.get("Username").getAsString()); + else if (server.has("username")) info.setUsername(server.get("username").getAsString()); - if (server.has("Password")) info.password = server.get("Password").getAsString(); - else if (server.has("password")) info.password = server.get("password").getAsString(); - else if (server.has("Credential")) info.password = server.get("Credential").getAsString(); - else if (server.has("credential")) info.password = server.get("credential").getAsString(); + if (server.has("Password")) info.setPassword(server.get("Password").getAsString()); + else if (server.has("password")) info.setPassword(server.get("password").getAsString()); + else if (server.has("Credential")) info.setPassword(server.get("Credential").getAsString()); + else if (server.has("credential")) info.setPassword(server.get("credential").getAsString()); - result.add(info); + result.add(info.build()); } } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java index d97e9e0..b334933 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java @@ -16,7 +16,7 @@ public class NetherNetScanner { public static void main(String[] args) throws Exception { long myNetworkId = ThreadLocalRandom.current().nextLong(); - dev.kastle.netty.channel.nethernet.signaling.NetherNetDiscovery discovery = new NetherNetDiscovery(myNetworkId); + NetherNetDiscovery discovery = new NetherNetDiscovery(myNetworkId); discovery.bind(new InetSocketAddress("::", 0)); diff --git a/transport-raknet/build.gradle.kts b/transport-raknet/build.gradle.kts index 4ad88ab..faaa947 100644 --- a/transport-raknet/build.gradle.kts +++ b/transport-raknet/build.gradle.kts @@ -14,6 +14,10 @@ * under the License. */ +plugins { + id("com.gradleup.nmcp") +} + description = "RakNet transport for Netty" dependencies { From 6a8915db93a1af5fefe9e80f82654bc87bfa3fc9 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 3 Jan 2026 22:07:20 -0800 Subject: [PATCH 13/22] Fix option access Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../nethernet/config/DefaultNetherClientChannelConfig.java | 4 ++-- .../nethernet/config/DefaultNetherServerChannelConfig.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java index ce300a3..51a6d87 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java @@ -26,7 +26,7 @@ public T getOption(ChannelOption option) { return (T) Integer.valueOf(this.clientHandshakeTimeoutMs); } - return this.channel.parent().config().getOption(option); + return super.getOption(option); } @Override @@ -37,7 +37,7 @@ public boolean setOption(ChannelOption option, T value) { this.setClientHandshakeTimeoutMs((Integer) value); return true; } else { - return this.channel.parent().config().setOption(option, value); + return super.setOption(option, value); } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java index 03cafc9..f683068 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java @@ -26,7 +26,7 @@ public T getOption(ChannelOption option) { return (T) Integer.valueOf(this.serverRtcHandshakeTimeoutSeconds); } - return this.channel.parent().config().getOption(option); + return super.getOption(option); } @Override @@ -37,7 +37,7 @@ public boolean setOption(ChannelOption option, T value) { this.setServerRtcHandshakeTimeoutSeconds((Integer) value); return true; } else { - return this.channel.parent().config().setOption(option, value); + return super.setOption(option, value); } } From d21d75bb24fcad10dbe211e0f34575c895db23ad Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:02:52 -0800 Subject: [PATCH 14/22] Add max retries for client connect Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../nethernet/NetherNetClientChannel.java | 91 +++++++++++++------ .../DefaultNetherClientChannelConfig.java | 16 +++- .../nethernet/config/NetherChannelOption.java | 6 ++ .../signaling/NetherNetDiscovery.java | 7 +- .../signaling/NetherNetXboxSignaling.java | 59 ++++++++++-- 5 files changed, 138 insertions(+), 41 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index 3c3c577..9d2f15d 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -28,6 +28,7 @@ import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; +import java.net.ConnectException; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.nio.channels.ClosedChannelException; @@ -51,7 +52,9 @@ public class NetherNetClientChannel extends NetherNetChannel { private volatile ScheduledFuture handshakeTimeoutTask; private volatile String localUfrag; - + + private int retryCount = 0; + /** * Creates a NetherNetClientChannel with a new PeerConnectionFactory. * @@ -135,10 +138,7 @@ private void startHandshake() { int handshakeTimeout = this.config().getOption(NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS); handshakeTimeoutTask = eventLoop().schedule(() -> { - if (!handshakeComplete) { - log.debug("Handshake timed out. Resetting and Retrying..."); - resetAndRetryHandshake(); - } + resetAndRetryHandshake(); }, handshakeTimeout, TimeUnit.MILLISECONDS); signaling.setSignalHandler(this.connectionId, this::handleSignal); @@ -152,18 +152,37 @@ private void startHandshake() { createAndSendOffer(); } } catch (Exception e) { - log.error("WebRTC Init failed", e); - // We don't fail promise here; we let the timeout task trigger a retry + // complete exceptionally on failure + log.error("Failed to start WebRTC handshake", e); + if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(e); + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + close(); } }, eventLoop()).exceptionally(e -> { log.error("Signaling connection failed", e); - // Again, let timeout handle the retry loop + if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(e); + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + close(); return null; }); } private void resetAndRetryHandshake() { if (!isOpen()) return; + if (connectPromise != null && connectPromise.isDone() && !connectPromise.isSuccess()) return; + if (handshakeComplete) return; + + // fail exceptionally if max retries reached + int maxRetries = this.config().getOption(NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS); + if (retryCount >= maxRetries) { + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Connection timed out after " + retryCount + " retries")); + } + close(); + return; + } + + retryCount++; if (peerConnection != null) { peerConnection.close(); @@ -197,28 +216,34 @@ public void onIceCandidate(RTCIceCandidate candidate) { log.warn("Generated ICE candidate before local ufrag was available. Skipping."); return; } - + String sdp = candidate.sdp.trim(); // Format: ufrag network-id network-cost 0 - StringBuilder sb = new StringBuilder(sdp); - sb.append(" ufrag ").append(localUfrag); - sb.append(" network-id ").append(signaling.getLocalNetworkId()); - sb.append(" network-cost 0"); - - signaling.sendSignal( - targetNetworkId, - NetherNetConstants.buildSignalCandidateAdd(connectionId, sb.toString()) - ); + StringBuilder sb = new StringBuilder(sdp) + .append(" ufrag ").append(localUfrag) + .append(" network-id ").append(signaling.getLocalNetworkId()) + .append(" network-cost 0"); + + try { + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalCandidateAdd(connectionId, sb.toString()) + ); + } catch (Exception e) { + log.error("Failed to send ICE candidate", e); + eventLoop().execute(() -> resetAndRetryHandshake()); + } } @Override public void onConnectionChange(RTCPeerConnectionState state) { if (state == RTCPeerConnectionState.FAILED) { // Fast fail trigger: retry immediately instead of waiting for timeout - eventLoop().execute(() -> { - if (!handshakeComplete) resetAndRetryHandshake(); - }); + log.warn("PeerConnection entered FAILED state, resetting and retrying handshake."); + eventLoop().execute(() -> resetAndRetryHandshake()); + } else { + log.debug("PeerConnection state changed to {}", state); } } @@ -254,10 +279,15 @@ public void onSuccess(RTCSessionDescription description) { peerConnection.setLocalDescription(description, new SetSessionDescriptionObserver() { @Override public void onSuccess() { - signaling.sendSignal( - targetNetworkId, - NetherNetConstants.buildSignalConnectRequest(connectionId, description.sdp) - ); + try { + signaling.sendSignal( + targetNetworkId, + NetherNetConstants.buildSignalConnectRequest(connectionId, description.sdp) + ); + } catch (Exception e) { + log.error("Failed to send Connect Request", e); + eventLoop().execute(() -> resetAndRetryHandshake()); + } } @Override public void onFailure(String error) { /* Retry handled by timeout */ } }); @@ -299,9 +329,14 @@ private void handleSignal(String signal) { peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); } case NetherNetConstants.SIGNAL_CONNECT_ERROR -> { - // Server rejected us (e.g. offline). Reset immediately. - log.debug("Received SIGNAL_CONNECT_ERROR for {}", Long.toUnsignedString(this.connectionId)); - resetAndRetryHandshake(); + log.error("Received SIGNAL_CONNECT_ERROR for {}.", Long.toUnsignedString(this.connectionId)); + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Remote peer sent connect error.")); + } + close(); + } + default -> { + log.debug("Received unknown signal type: {}", type); } } }); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java index 51a6d87..7c5456c 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java @@ -6,7 +6,8 @@ import java.util.Map; public class DefaultNetherClientChannelConfig extends DefaultNetherChannelConfig { - private volatile int clientHandshakeTimeoutMs = 1000; + private volatile int clientHandshakeTimeoutMs = 3000; + private volatile int maxHandshakeAttempts = 3; public DefaultNetherClientChannelConfig(Channel channel) { super(channel); @@ -15,7 +16,9 @@ public DefaultNetherClientChannelConfig(Channel channel) { @Override public Map, Object> getOptions() { return this.getOptions( - super.getOptions(), NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS + super.getOptions(), + NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS, + NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS ); } @@ -24,6 +27,8 @@ public Map, Object> getOptions() { public T getOption(ChannelOption option) { if (option == NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS) { return (T) Integer.valueOf(this.clientHandshakeTimeoutMs); + } else if (option == NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS) { + return (T) Integer.valueOf(this.maxHandshakeAttempts); } return super.getOption(option); @@ -36,6 +41,9 @@ public boolean setOption(ChannelOption option, T value) { if (option == NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS) { this.setClientHandshakeTimeoutMs((Integer) value); return true; + } else if (option == NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS) { + this.setMaxHandshakeAttempts((Integer) value); + return true; } else { return super.setOption(option, value); } @@ -44,4 +52,8 @@ public boolean setOption(ChannelOption option, T value) { void setClientHandshakeTimeoutMs(int clientHandshakeTimeoutMs) { this.clientHandshakeTimeoutMs = clientHandshakeTimeoutMs; } + + void setMaxHandshakeAttempts(int maxHandshakeAttempts) { + this.maxHandshakeAttempts = maxHandshakeAttempts; + } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java index 14e5283..64c8a2a 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java @@ -10,6 +10,12 @@ public class NetherChannelOption extends ChannelOption { public static final ChannelOption NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS = valueOf(NetherChannelOption.class, "NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS"); + /** + * The maximum number of handshake attempts before giving up on connecting. + */ + public static final ChannelOption NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS = + valueOf(NetherChannelOption.class, "NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS"); + /** * The timeout in seconds for completing the WebRTC handshake on the server side before automatically closing the connection. */ diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java index 6c24166..9abe329 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java @@ -39,8 +39,7 @@ public class NetherNetDiscovery extends SimpleChannelInboundHandler implements NetherNetClientSignaling, NetherNetServerSignaling { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetXboxSignaling.class); private static final Gson gson = new Gson(); @@ -90,8 +94,13 @@ public synchronized CompletableFuture> connect(SocketAddress @Override public void bind(SocketAddress localAddress) { - // SocketAddress is ignored, we connect to the WS URL derived from NetworkID - connectInternal(); + try { + connectInternal().join(); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + close(); + throw new RuntimeException("Failed to bind Xbox Signaling: " + cause.getMessage(), cause); + } } private synchronized CompletableFuture> connectInternal() { @@ -159,10 +168,33 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc } } + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { + if (connectFuture != null && !connectFuture.isDone()) { + connectFuture.completeExceptionally(cause); + } + log.error("Signaling Exception: {}", cause.getMessage(), cause); + ctx.close(); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + synchronized (this) { + if (connectFuture != null) { + if (!connectFuture.isDone()) { + connectFuture.completeExceptionally(new ClosedChannelException()); + } + connectFuture = null; + } + this.channel = null; + } + super.channelInactive(ctx); + } + private void startPingLoop(ChannelHandlerContext ctx) { ctx.executor().scheduleAtFixedRate(() -> { JsonObject ping = new JsonObject(); - ping.addProperty("Type", 0); // RequestType::Ping + ping.addProperty("Type", 0); ctx.writeAndFlush(new TextWebSocketFrame(gson.toJson(ping))); }, 5, 5, TimeUnit.SECONDS); } @@ -176,7 +208,7 @@ public void sendSignal(String targetNetworkId, String data) { msg.addProperty("Message", data); channel.writeAndFlush(new TextWebSocketFrame(gson.toJson(msg))); } else { - log.debug("Attempted to send signal to {} but WebSocket is closed or null!", targetNetworkId); + throw new IllegalStateException("Attempted to send signal to " + targetNetworkId + " but WebSocket is closed or null!"); } } @@ -195,16 +227,24 @@ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) String text = frame.text(); try { JsonObject json = gson.fromJson(text, JsonObject.class); - if (!json.has("Type")) return; + if (!json.has("Type")) { + log.debug("Received signaling message without Type: {}", text); + return; + } int type = json.get("Type").getAsInt(); switch (type) { + case 3 -> { // Accepted + log.debug("Received Accepted message (3): {}", text); + } case 2 -> { // Credentials + log.debug("Received Credentials message (2): {}", text); if (json.has("Message") && !connectFuture.isDone()) { connectFuture.complete(parseTurnServers(json.get("Message").getAsString())); } } case 1 -> { // Signal + log.debug("Received Signal message (1): {}", text); String sender = "0"; if (json.has("From")) { sender = json.get("From").getAsString(); @@ -213,7 +253,13 @@ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) if (json.has("Message")) { String rawMsg = json.get("Message").getAsString(); dispatchSignal(sender, rawMsg); - } + } + } + case 0 -> { // Not found + log.debug("Received Not Found message for Network ID {} (0): {}", this.localNetworkId, text); + } + default -> { + log.debug("Received unknown signaling message type {}: {}", type, text); } } } catch (Exception e) { @@ -222,7 +268,6 @@ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) } private void dispatchSignal(String sender, String rawMsg) { - // Format: TYPE CONNECTION_ID PAYLOAD try { String[] parts = rawMsg.split(" ", 3); if (parts.length >= 2) { From 8c31704824c9377808c868df84e7effa7c622579 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:49:12 -0800 Subject: [PATCH 15/22] Fail connect promise if target network id is not found Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../nethernet/NetherNetClientChannel.java | 10 ++++++++-- .../signaling/NetherNetClientSignaling.java | 10 ++++++++++ .../signaling/NetherNetDiscoverySignaling.java | 5 +++++ .../signaling/NetherNetXboxSignaling.java | 16 ++++++++++++---- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index 9d2f15d..7e77dcb 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -136,6 +136,13 @@ private void startHandshake() { if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + signaling.setNotFoundHandler(msg -> { + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ConnectException("Target Network ID " + this.targetNetworkId + " not found or offline.")); + } + close(); + }); + int handshakeTimeout = this.config().getOption(NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS); handshakeTimeoutTask = eventLoop().schedule(() -> { resetAndRetryHandshake(); @@ -152,7 +159,6 @@ private void startHandshake() { createAndSendOffer(); } } catch (Exception e) { - // complete exceptionally on failure log.error("Failed to start WebRTC handshake", e); if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(e); if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); @@ -243,7 +249,7 @@ public void onConnectionChange(RTCPeerConnectionState state) { log.warn("PeerConnection entered FAILED state, resetting and retrying handshake."); eventLoop().execute(() -> resetAndRetryHandshake()); } else { - log.debug("PeerConnection state changed to {}", state); + log.trace("PeerConnection state changed to {}", state); } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java index 6198da5..d451c2d 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java @@ -3,10 +3,20 @@ import java.net.SocketAddress; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; public interface NetherNetClientSignaling extends NetherNetSignaling { /** * Connects to the signaling medium (Client mode). + * + * @param remoteAddress The address of the signaling server to connect to. */ CompletableFuture> connect(SocketAddress remoteAddress); + + /** + * Sets a handler to be called when a signaling message is received for an unknown connection ID. + * + * @param handler The handler to process incoming signaling messages for unknown connection IDs. + */ + void setNotFoundHandler(Consumer handler); } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java index b5ef2ef..8b8499d 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -158,6 +158,11 @@ public void removeSignalHandler(long connectionId) { this.discovery.unregisterSignalHandler(connectionId); } + @Override + public void setNotFoundHandler(Consumer handler) { + // Not implemented for Discovery signaling + } + @Override public void close() { this.discovery.close(); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index deadc1d..9b5abb9 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -30,7 +30,6 @@ import java.net.SocketAddress; import java.net.URI; -import java.net.UnknownHostException; import java.nio.channels.ClosedChannelException; import java.util.ArrayList; import java.util.List; @@ -57,6 +56,7 @@ public class NetherNetXboxSignaling extends SimpleChannelInboundHandler> handlers = new ConcurrentHashMap<>(); private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; + private volatile Consumer notFoundHandler; private volatile List iceServers = new ArrayList<>(); @@ -152,6 +152,11 @@ public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandle this.newConnectionHandler = handler; } + @Override + public void setNotFoundHandler(Consumer handler) { + this.notFoundHandler = handler; + } + @Override public void setAdvertisementData(PongData pongData) { // No-op for Xbox Signaling. @@ -235,16 +240,16 @@ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) int type = json.get("Type").getAsInt(); switch (type) { case 3 -> { // Accepted - log.debug("Received Accepted message (3): {}", text); + log.trace("Received Accepted message (3): {}", text); } case 2 -> { // Credentials - log.debug("Received Credentials message (2): {}", text); + log.trace("Received Credentials message (2): {}", text); if (json.has("Message") && !connectFuture.isDone()) { connectFuture.complete(parseTurnServers(json.get("Message").getAsString())); } } case 1 -> { // Signal - log.debug("Received Signal message (1): {}", text); + log.trace("Received Signal message (1): {}", text); String sender = "0"; if (json.has("From")) { sender = json.get("From").getAsString(); @@ -257,6 +262,9 @@ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) } case 0 -> { // Not found log.debug("Received Not Found message for Network ID {} (0): {}", this.localNetworkId, text); + if (notFoundHandler != null) { + notFoundHandler.accept(text); + } } default -> { log.debug("Received unknown signaling message type {}: {}", type, text); From bc30e63164be179f193d3606d5fbf25394c2c329 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:52:52 -0800 Subject: [PATCH 16/22] Add case for Delivery Acknowledgement Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../channel/nethernet/signaling/NetherNetXboxSignaling.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index 9b5abb9..a706526 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -239,6 +239,9 @@ protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) int type = json.get("Type").getAsInt(); switch (type) { + case 4 -> { // Delivery Acknowledgement + log.trace("Received Delivery Acknowledgement (4): {}", text); + } case 3 -> { // Accepted log.trace("Received Accepted message (3): {}", text); } From 32e5d19311290200082cfccd36cca945bdc8b86d Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 10 Jan 2026 00:01:36 -0800 Subject: [PATCH 17/22] Refactor to use specific functional interfaces for all string handlers; javadoc formatting Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../channel/nethernet/NetherNetChannel.java | 1 - .../nethernet/NetherNetChildChannel.java | 2 + .../nethernet/NetherNetClientChannel.java | 10 +- .../channel/nethernet/NetherNetConstants.java | 23 ++- .../nethernet/NetherNetServerChannel.java | 32 ++-- .../signaling/NetherNetClientSignaling.java | 16 +- .../signaling/NetherNetDiscovery.java | 15 +- .../NetherNetDiscoverySignaling.java | 9 +- .../signaling/NetherNetServerSignaling.java | 1 + .../signaling/NetherNetSignaling.java | 20 ++- .../signaling/NetherNetXboxSignaling.java | 168 ++++++++++-------- 11 files changed, 172 insertions(+), 125 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java index 6dfd05a..3318d4d 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -43,7 +43,6 @@ protected NetherNetChannel(Channel parent, InetSocketAddress remote, InetSocketA super(parent); this.remoteAddress = remote; this.localAddress = local; - this.config = new DefaultNetherChannelConfig(this); } public void setDataChannels(RTCDataChannel reliable, RTCDataChannel unreliable) { diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java index 758850d..c324e4e 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java @@ -1,5 +1,6 @@ package dev.kastle.netty.channel.nethernet; +import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; import dev.kastle.webrtc.RTCPeerConnection; import io.netty.channel.Channel; import io.netty.channel.ChannelPromise; @@ -11,6 +12,7 @@ public class NetherNetChildChannel extends NetherNetChannel { public NetherNetChildChannel(Channel parent, RTCPeerConnection peerConnection, InetSocketAddress remote, InetSocketAddress local) { super(parent, remote, local); this.peerConnection = peerConnection; + this.config = new DefaultNetherChannelConfig(this); } @Override diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index 7e77dcb..f652524 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -67,7 +67,7 @@ public NetherNetClientChannel(NetherNetClientSignaling signaling) { /** * Creates a NetherNetClientChannel. * - * @param factory The PeerConnectionFactory to use. Should be reused where possible. + * @param factory The PeerConnectionFactory to use. Should be reused where possible. * @param signaling The NetherNetClientSignaling instance for signaling. */ public NetherNetClientChannel(PeerConnectionFactory factory, NetherNetClientSignaling signaling) { @@ -136,7 +136,7 @@ private void startHandshake() { if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); - signaling.setNotFoundHandler(msg -> { + signaling.setNotFoundHandler(reason -> { if (connectPromise != null && !connectPromise.isDone()) { connectPromise.tryFailure(new ConnectException("Target Network ID " + this.targetNetworkId + " not found or offline.")); } @@ -325,16 +325,16 @@ private void handleSignal(String signal) { if (!isOpen() || handshakeComplete) return; switch (type) { - case NetherNetConstants.SIGNAL_CONNECT_RESPONSE -> { + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_RESPONSE -> { peerConnection.setRemoteDescription(new RTCSessionDescription(RTCSdpType.ANSWER, data), new SetSessionDescriptionObserver() { @Override public void onSuccess() {} @Override public void onFailure(String e) { /* Retry handled by timeout */ } }); } - case NetherNetConstants.SIGNAL_CANDIDATE_ADD -> { + case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); } - case NetherNetConstants.SIGNAL_CONNECT_ERROR -> { + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_ERROR -> { log.error("Received SIGNAL_CONNECT_ERROR for {}.", Long.toUnsignedString(this.connectionId)); if (connectPromise != null && !connectPromise.isDone()) { connectPromise.tryFailure(new ConnectException("Remote peer sent connect error.")); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java index 4b47bcd..abf5899 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -21,11 +21,18 @@ public class NetherNetConstants { public static final int ID_DISCOVERY_RESPONSE = 0x01; public static final int ID_DISCOVERY_MESSAGE = 0x02; - // Signaling Message Types - public static final String SIGNAL_CONNECT_REQUEST = "CONNECTREQUEST"; - public static final String SIGNAL_CONNECT_RESPONSE = "CONNECTRESPONSE"; - public static final String SIGNAL_CANDIDATE_ADD = "CANDIDATEADD"; - public static final String SIGNAL_CONNECT_ERROR = "CONNECTERROR"; + // WebRTC Negotiation Message Types + public static final String RTC_NEGOTIATION_CONNECT_REQUEST = "CONNECTREQUEST"; + public static final String RTC_NEGOTIATION_CONNECT_RESPONSE = "CONNECTRESPONSE"; + public static final String RTC_NEGOTIATION_CANDIDATE_ADD = "CANDIDATEADD"; + public static final String RTC_NEGOTIATION_CONNECT_ERROR = "CONNECTERROR"; + + // Xbox Signaling Message Types + public static final int XBOX_SIGNAL_NOT_FOUND = 0; + public static final int XBOX_SIGNAL_SIGNAL = 1; + public static final int XBOX_SIGNAL_CREDENTIALS = 2; + public static final int XBOX_SIGNAL_ACCEPTED = 3; + public static final int XBOX_SIGNAL_ACK = 4; // SCTP Constants public static final int MAX_SCTP_MESSAGE_SIZE = 10000; @@ -134,7 +141,7 @@ public static ByteBuf decryptDiscoveryPacket(ByteBuf input) throws Exception { * @return The formatted signaling message. */ public static String buildSignalConnectRequest(long connectionId, String sdp) { - return SIGNAL_CONNECT_REQUEST + " " + Long.toUnsignedString(connectionId) + " " + sdp; + return RTC_NEGOTIATION_CONNECT_REQUEST + " " + Long.toUnsignedString(connectionId) + " " + sdp; } /** @@ -145,7 +152,7 @@ public static String buildSignalConnectRequest(long connectionId, String sdp) { * @return The formatted signaling message. */ public static String buildSignalConnectResponse(long connectionId, String sdp) { - return SIGNAL_CONNECT_RESPONSE + " " + Long.toUnsignedString(connectionId) + " " + sdp; + return RTC_NEGOTIATION_CONNECT_RESPONSE + " " + Long.toUnsignedString(connectionId) + " " + sdp; } /** @@ -156,6 +163,6 @@ public static String buildSignalConnectResponse(long connectionId, String sdp) { * @return The formatted signaling message. */ public static String buildSignalCandidateAdd(long connectionId, String candidateSdp) { - return SIGNAL_CANDIDATE_ADD + " " + Long.toUnsignedString(connectionId) + " " + candidateSdp; + return RTC_NEGOTIATION_CANDIDATE_ADD + " " + Long.toUnsignedString(connectionId) + " " + candidateSdp; } } \ No newline at end of file diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index d542d2e..a281416 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -4,7 +4,6 @@ import dev.kastle.netty.channel.nethernet.config.NetherChannelOption; import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling; import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo; -import dev.kastle.netty.channel.nethernet.signaling.NetherNetXboxSignaling; import dev.kastle.webrtc.CreateSessionDescriptionObserver; import dev.kastle.webrtc.PeerConnectionFactory; import dev.kastle.webrtc.PeerConnectionObserver; @@ -55,7 +54,7 @@ public NetherNetServerChannel(NetherNetServerSignaling signaling) { /** * Creates a NetherNetServerChannel. * - * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. + * @param factory The PeerConnectionFactory to use for creating peer connections. Should be reused where possible. * @param signaling The NetherNetServerSignaling instance for signaling. */ public NetherNetServerChannel(PeerConnectionFactory factory, NetherNetServerSignaling signaling) { @@ -81,22 +80,15 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; // Inject ICE servers if the signaling implementation supports it - if (this.signaling instanceof NetherNetXboxSignaling) { - NetherNetXboxSignaling xboxSignaling = (NetherNetXboxSignaling) this.signaling; - List iceServers = xboxSignaling.getIceServers(); - - if (iceServers != null && !iceServers.isEmpty()) { - log.trace("Injecting {} ICE Servers into PeerConnection for {}", iceServers.size(), Long.toUnsignedString(connectionId)); - for (IceServerInfo info : iceServers) { - RTCIceServer iceServer = new RTCIceServer(); - iceServer.urls = info.urls(); - iceServer.username = info.username(); - iceServer.password = info.password(); - rtcConfig.iceServers.add(iceServer); - log.trace(" - Added ICE Server: {} (User: {})", info.urls(), info.username()); - } - } else { - log.warn("NetherNetXboxSignaling has NO ICE servers available! WAN connections will likely fail."); + List iceServers = this.signaling.getIceServers(); + if (iceServers != null && !iceServers.isEmpty()) { + log.trace("Injecting {} ICE Servers into PeerConnection for {}", iceServers.size(), Long.toUnsignedString(connectionId)); + for (IceServerInfo info : iceServers) { + RTCIceServer iceServer = new RTCIceServer(); + iceServer.urls = info.urls(); + iceServer.username = info.username(); + iceServer.password = info.password(); + rtcConfig.iceServers.add(iceServer); } } @@ -124,11 +116,11 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe String data = parts[2]; switch (type) { - case NetherNetConstants.SIGNAL_CANDIDATE_ADD -> { + case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { log.trace("Applying Remote Candidate for {}: {}", Long.toUnsignedString(connectionId), data); pc.addIceCandidate(new RTCIceCandidate("0", 0, data)); } - case NetherNetConstants.SIGNAL_CONNECT_ERROR -> { + case NetherNetConstants.RTC_NEGOTIATION_CONNECT_ERROR -> { log.debug("Received CONNECT_ERROR for {}", Long.toUnsignedString(connectionId)); child.close(); } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java index d451c2d..6640e23 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java @@ -3,7 +3,6 @@ import java.net.SocketAddress; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; public interface NetherNetClientSignaling extends NetherNetSignaling { /** @@ -18,5 +17,18 @@ public interface NetherNetClientSignaling extends NetherNetSignaling { * * @param handler The handler to process incoming signaling messages for unknown connection IDs. */ - void setNotFoundHandler(Consumer handler); + void setNotFoundHandler(NotFoundHandler handler); + + /** + * Functional interface for handling "Not Found" signals. + */ + @FunctionalInterface + interface NotFoundHandler { + /** + * Called when the signaling service indicates the target peer was not found. + * + * @param reason The reason or raw message payload regarding the failure. + */ + void onNotFound(String reason); + } } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java index 9abe329..58ca7b2 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java @@ -2,6 +2,7 @@ import dev.kastle.netty.channel.nethernet.NetherNetConstants; import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling.PongData; +import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.SignalHandler; import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; @@ -24,13 +25,12 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; -import java.util.function.Consumer; public class NetherNetDiscovery extends SimpleChannelInboundHandler { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscovery.class); private final long networkId; - private final Map> signalHandlers = new ConcurrentHashMap<>(); + private final Map signalHandlers = new ConcurrentHashMap<>(); private final Map peerAddresses = new ConcurrentHashMap<>(); private Channel channel; private byte[] pongData; @@ -39,7 +39,8 @@ public class NetherNetDiscovery extends SimpleChannelInboundHandler handler) { + public void registerSignalHandler(long connectionId, SignalHandler handler) { this.signalHandlers.put(connectionId, handler); } @@ -275,11 +276,11 @@ private void handleMessage(ByteBuf data, long senderId) { String type = parts[0]; long connectionId = Long.parseUnsignedLong(parts[1]); - Consumer handler = signalHandlers.get(connectionId); + SignalHandler handler = signalHandlers.get(connectionId); if (handler != null) { - handler.accept(messageData); - } else if (NetherNetConstants.SIGNAL_CONNECT_REQUEST.equals(type)) { + handler.onSignal(messageData); + } else if (NetherNetConstants.RTC_NEGOTIATION_CONNECT_REQUEST.equals(type)) { if (newConnectionHandler != null) { String payload = parts.length > 2 ? parts[2] : ""; log.trace("Dispatching New Connection: ID={} Sender={}", Long.toUnsignedString(connectionId), Long.toUnsignedString(senderId)); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java index 8b8499d..16479d0 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -11,7 +11,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; public class NetherNetDiscoverySignaling implements NetherNetClientSignaling, NetherNetServerSignaling { private static final InternalLogger log = InternalLoggerFactory.getInstance(NetherNetDiscoverySignaling.class); @@ -33,6 +32,7 @@ public NetherNetDiscoverySignaling() { /** * Creates a NetherNetDiscoverySignaling with the specified local Network ID. + * * @param localNetworkId The local Network ID to use. */ public NetherNetDiscoverySignaling(long localNetworkId) { @@ -41,8 +41,9 @@ public NetherNetDiscoverySignaling(long localNetworkId) { /** * Creates a NetherNetDiscoverySignaling with the specified local Network ID and bind address. + * * @param localNetworkId The local Network ID to use. - * @param bindAddress The address to bind the discovery socket to. + * @param bindAddress The address to bind the discovery socket to. */ public NetherNetDiscoverySignaling(long localNetworkId, InetSocketAddress bindAddress) { this.localNetworkId = Long.toUnsignedString(localNetworkId); @@ -149,7 +150,7 @@ public void sendSignal(String targetNetworkId, String data) { } @Override - public void setSignalHandler(long connectionId, Consumer handler) { + public void setSignalHandler(long connectionId, SignalHandler handler) { this.discovery.registerSignalHandler(connectionId, handler); } @@ -159,7 +160,7 @@ public void removeSignalHandler(long connectionId) { } @Override - public void setNotFoundHandler(Consumer handler) { + public void setNotFoundHandler(NetherNetClientSignaling.NotFoundHandler handler) { // Not implemented for Discovery signaling } diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java index a3085a0..2e1166c 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -11,6 +11,7 @@ public interface NetherNetServerSignaling extends NetherNetSignaling { /** * Handler for new connections. + * * @param handler Functional interface receiving (ConnectionID, RemoteNetworkID, Payload) */ void setNewConnectionHandler(NewConnectionHandler handler); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java index ddd1657..fe63d3b 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java @@ -1,7 +1,6 @@ package dev.kastle.netty.channel.nethernet.signaling; import java.util.List; -import java.util.function.Consumer; public interface NetherNetSignaling extends AutoCloseable { @@ -15,15 +14,15 @@ public interface NetherNetSignaling extends AutoCloseable { /** * Sets a handler to receive signaling messages for a specific connection ID. - * + * * @param connectionId The connection ID to listen for. * @param handler The handler to process incoming signaling messages. */ - void setSignalHandler(long connectionId, Consumer handler); + void setSignalHandler(long connectionId, SignalHandler handler); /** * Removes the signaling handler for a specific connection ID. - * + * * @param connectionId The connection ID whose handler should be removed. */ void removeSignalHandler(long connectionId); @@ -40,6 +39,19 @@ public interface NetherNetSignaling extends AutoCloseable { @Override void close(); + /** + * Functional interface for handling incoming signals. + */ + @FunctionalInterface + interface SignalHandler { + /** + * Called when a signal is received for the registered connection ID. + * + * @param signal The raw signal payload. + */ + void onSignal(String signal); + } + /** * Data structure for ICE server information. * diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index a706526..b9a94e8 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -4,6 +4,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import dev.kastle.netty.channel.nethernet.NetherNetConstants; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; @@ -39,7 +40,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; @Sharable public class NetherNetXboxSignaling extends SimpleChannelInboundHandler implements NetherNetClientSignaling, NetherNetServerSignaling { @@ -54,9 +54,9 @@ public class NetherNetXboxSignaling extends SimpleChannelInboundHandler> connectFuture; - private final Map> handlers = new ConcurrentHashMap<>(); + private final Map handlers = new ConcurrentHashMap<>(); private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; - private volatile Consumer notFoundHandler; + private volatile NetherNetClientSignaling.NotFoundHandler notFoundHandler; private volatile List iceServers = new ArrayList<>(); @@ -73,10 +73,21 @@ public NetherNetXboxSignaling(String networkId, String xboxToken) { this.eventLoopGroup = new NioEventLoopGroup(1); } + /** + * Creates a NetherNetXboxSignaling instance. + * + * @param localNetworkId The local Network ID to use. + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ public NetherNetXboxSignaling(long localNetworkId, String xboxToken) { this(Long.toUnsignedString(localNetworkId), xboxToken); } + /** + * Creates a NetherNetXboxSignaling instance with a random local Network ID. + * + * @param xboxToken The Minecraft Bedrock Session authorization header ('MCToken ***'). + */ public NetherNetXboxSignaling(String xboxToken) { this(Long.toUnsignedString(ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE)), xboxToken); } @@ -153,7 +164,7 @@ public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandle } @Override - public void setNotFoundHandler(Consumer handler) { + public void setNotFoundHandler(NotFoundHandler handler) { this.notFoundHandler = handler; } @@ -173,6 +184,56 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc } } + @Override + protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { + String text = frame.text(); + try { + JsonObject json = gson.fromJson(text, JsonObject.class); + if (!json.has("Type")) { + log.debug("Ignored message without Type: {}", text); + return; + } + + int type = json.get("Type").getAsInt(); + switch (type) { + case NetherNetConstants.XBOX_SIGNAL_NOT_FOUND -> handleNotFound(json, text); + case NetherNetConstants.XBOX_SIGNAL_SIGNAL -> handleSignal(json); + case NetherNetConstants.XBOX_SIGNAL_CREDENTIALS -> handleCredentials(json, text); + case NetherNetConstants.XBOX_SIGNAL_ACCEPTED -> log.trace("Signal Accepted: {}", text); + case NetherNetConstants.XBOX_SIGNAL_ACK -> log.trace("Delivery Ack: {}", text); + default -> log.debug("Unknown message type {}: {}", type, text); + } + } catch (Exception e) { + log.error("Error processing signaling frame: " + text, e); + } + } + + private void handleNotFound(JsonObject json, String rawText) { + log.debug("Peer Not Found. Payload: {}", rawText); + if (notFoundHandler != null) { + String reason = json.has("Message") ? json.get("Message").getAsString() : rawText; + notFoundHandler.onNotFound(reason); + } + } + + private void handleSignal(JsonObject json) { + String sender = json.has("From") ? json.get("From").getAsString() : "0"; + if (!json.has("Message")) { + log.warn("Received SIGNAL (1) without Message payload."); + return; + } + + String rawMsg = json.get("Message").getAsString(); + dispatchSignalToPipeline(sender, rawMsg); + } + + private void handleCredentials(JsonObject json, String rawText) { + log.trace("Received Credentials: {}", rawText); + if (json.has("Message") && connectFuture != null && !connectFuture.isDone()) { + connectFuture.complete(parseTurnServers(json.get("Message").getAsString())); + } + } + @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { if (connectFuture != null && !connectFuture.isDone()) { @@ -182,6 +243,34 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws E ctx.close(); } + private void dispatchSignalToPipeline(String sender, String rawMsg) { + try { + // Signal Format: + String[] parts = rawMsg.split(" ", 3); + if (parts.length < 2) return; + + long connectionId = Long.parseUnsignedLong(parts[1]); + + // Try specific connection handlers (Existing Connections) + SignalHandler handler = handlers.get(connectionId); + if (handler != null) { + handler.onSignal(rawMsg); + return; + } + + // Try New Connection Handler (Server Mode) + if (NetherNetConstants.RTC_NEGOTIATION_CONNECT_REQUEST.equals(parts[0]) && newConnectionHandler != null) { + String payload = parts.length > 2 ? parts[2] : ""; + newConnectionHandler.onConnect(connectionId, sender, payload); + } + + } catch (NumberFormatException e) { + log.debug("Malformed Connection ID in signal: {}", rawMsg); + } catch (Exception e) { + log.error("Failed to dispatch signal: {}", rawMsg, e); + } + } + @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { synchronized (this) { @@ -218,7 +307,7 @@ public void sendSignal(String targetNetworkId, String data) { } @Override - public void setSignalHandler(long connectionId, Consumer handler) { + public void setSignalHandler(long connectionId, SignalHandler handler) { this.handlers.put(connectionId, handler); } @@ -226,75 +315,6 @@ public void setSignalHandler(long connectionId, Consumer handler) { public void removeSignalHandler(long connectionId) { this.handlers.remove(connectionId); } - - @Override - protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { - String text = frame.text(); - try { - JsonObject json = gson.fromJson(text, JsonObject.class); - if (!json.has("Type")) { - log.debug("Received signaling message without Type: {}", text); - return; - } - - int type = json.get("Type").getAsInt(); - switch (type) { - case 4 -> { // Delivery Acknowledgement - log.trace("Received Delivery Acknowledgement (4): {}", text); - } - case 3 -> { // Accepted - log.trace("Received Accepted message (3): {}", text); - } - case 2 -> { // Credentials - log.trace("Received Credentials message (2): {}", text); - if (json.has("Message") && !connectFuture.isDone()) { - connectFuture.complete(parseTurnServers(json.get("Message").getAsString())); - } - } - case 1 -> { // Signal - log.trace("Received Signal message (1): {}", text); - String sender = "0"; - if (json.has("From")) { - sender = json.get("From").getAsString(); - } - - if (json.has("Message")) { - String rawMsg = json.get("Message").getAsString(); - dispatchSignal(sender, rawMsg); - } - } - case 0 -> { // Not found - log.debug("Received Not Found message for Network ID {} (0): {}", this.localNetworkId, text); - if (notFoundHandler != null) { - notFoundHandler.accept(text); - } - } - default -> { - log.debug("Received unknown signaling message type {}: {}", type, text); - } - } - } catch (Exception e) { - log.error("Signaling error processing frame: " + text, e); - } - } - - private void dispatchSignal(String sender, String rawMsg) { - try { - String[] parts = rawMsg.split(" ", 3); - if (parts.length >= 2) { - long connectionId = Long.parseUnsignedLong(parts[1]); - Consumer handler = handlers.get(connectionId); - if (handler != null) { - handler.accept(rawMsg); - } else if ("CONNECTREQUEST".equals(parts[0]) && newConnectionHandler != null) { - String payload = parts.length > 2 ? parts[2] : ""; - newConnectionHandler.onConnect(connectionId, sender, payload); - } - } - } catch (Exception e) { - log.debug("Failed to dispatch signal: {}", rawMsg); - } - } private List parseTurnServers(String jsonString) { List result = new ArrayList<>(); From ae2aba573f1217f8384a2454a63bb65940f134d8 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 10 Jan 2026 00:48:49 -0800 Subject: [PATCH 18/22] Use connectexception if we can't reach xbox signaling Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../signaling/NetherNetServerSignaling.java | 6 +++++- .../nethernet/signaling/NetherNetXboxSignaling.java | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java index 2e1166c..a15b53a 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -1,13 +1,17 @@ package dev.kastle.netty.channel.nethernet.signaling; +import java.net.ConnectException; import java.net.SocketAddress; import java.util.List; public interface NetherNetServerSignaling extends NetherNetSignaling { /** * Binds the signaling medium to listen for incoming connections (Server mode). + * + * @param localAddress The local address to bind to. + * @throws ConnectException */ - void bind(SocketAddress localAddress); + void bind(SocketAddress localAddress) throws ConnectException; /** * Handler for new connections. diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index b9a94e8..9941113 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -29,6 +29,7 @@ import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; +import java.net.ConnectException; import java.net.SocketAddress; import java.net.URI; import java.nio.channels.ClosedChannelException; @@ -104,13 +105,20 @@ public synchronized CompletableFuture> connect(SocketAddress } @Override - public void bind(SocketAddress localAddress) { + public void bind(SocketAddress localAddress) throws ConnectException { try { connectInternal().join(); } catch (Exception e) { Throwable cause = e.getCause() != null ? e.getCause() : e; close(); - throw new RuntimeException("Failed to bind Xbox Signaling: " + cause.getMessage(), cause); + + if (cause instanceof ConnectException) { + throw (ConnectException) cause; + } + + ConnectException ce = new ConnectException("Failed to connect to Xbox Signaling: " + cause.getMessage()); + ce.initCause(cause); + throw ce; } } From c7685d5650e69e6781808673dd74469a687a0600 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:14:42 -0800 Subject: [PATCH 19/22] Complete with ConnectException if we cannot connect to xbox signaling wss; ensure signaling handler is removed on close Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../channel/nethernet/NetherNetServerChannel.java | 8 +++++++- .../nethernet/signaling/NetherNetXboxSignaling.java | 10 ++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java index a281416..626b696 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -98,6 +98,8 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe NetherNetChildChannel child = new NetherNetChildChannel(this, pc, new InetSocketAddress(0), localAddress); observer.setChildChannel(child); + child.closeFuture().addListener(future -> signaling.removeSignalHandler(connectionId)); + int handshakeTimeoutSeconds = this.config.getOption(NetherChannelOption.NETHER_SERVER_RTC_HANDSHAKE_TIMEOUT_SECONDS); ScheduledFuture timeoutTask = eventLoop().schedule(() -> { if (!child.isActive()) { @@ -118,7 +120,11 @@ public void acceptConnection(long connectionId, String offerSdp, String remoteNe switch (type) { case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { log.trace("Applying Remote Candidate for {}: {}", Long.toUnsignedString(connectionId), data); - pc.addIceCandidate(new RTCIceCandidate("0", 0, data)); + try { + pc.addIceCandidate(new RTCIceCandidate("0", 0, data)); + } catch (Exception e) { + log.debug("Failed to apply ICE candidate for {} (Connection likely closed): {}", Long.toUnsignedString(connectionId), e.toString()); + } } case NetherNetConstants.RTC_NEGOTIATION_CONNECT_ERROR -> { log.debug("Received CONNECT_ERROR for {}", Long.toUnsignedString(connectionId)); diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java index 9941113..f2fc411 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -156,8 +156,14 @@ protected void initChannel(SocketChannel ch) { this.channel = b.connect(uri.getHost(), 443).sync().channel(); } catch (Exception e) { - log.error("Failed to connect to signaling service", e); - connectFuture.completeExceptionally(e); + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof ConnectException) { + connectFuture.completeExceptionally(cause); + } else { + ConnectException ce = new ConnectException("Failed to connect to Xbox Signaling: " + cause.getMessage()); + ce.initCause(cause); + connectFuture.completeExceptionally(ce); + } } return connectFuture; } From 5a50d9f489f1126a1c5faf1b846eb3fb22562666 Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:44:28 -0800 Subject: [PATCH 20/22] Use ConnectException for handshake failure Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .../channel/nethernet/NetherNetClientChannel.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java index f652524..31aa618 100644 --- a/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -159,14 +159,16 @@ private void startHandshake() { createAndSendOffer(); } } catch (Exception e) { - log.error("Failed to start WebRTC handshake", e); - if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(e); + ConnectException ce = new ConnectException("Failed to start WebRTC handshake: " + e.getMessage()); + ce.initCause(e); + if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(ce); if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); close(); } }, eventLoop()).exceptionally(e -> { - log.error("Signaling connection failed", e); - if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(e); + ConnectException ce = new ConnectException("Signaling connection failed: " + e.getMessage()); + ce.initCause(e); + if (connectPromise != null && !connectPromise.isDone()) connectPromise.tryFailure(ce); if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); close(); return null; From 20d3e920791a7d788928a0ec8efdb286aff3d3bc Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:46:56 -0800 Subject: [PATCH 21/22] Update README structure Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- .github/readme/nethernet_client_dark.mmd | 40 +++++++++ .github/readme/nethernet_client_dark.svg | 102 ++++++++++++++++++++++ .github/readme/nethernet_client_light.mmd | 40 +++++++++ .github/readme/nethernet_client_light.svg | 102 ++++++++++++++++++++++ .github/readme/nethernet_server_dark.mmd | 46 ++++++++++ .github/readme/nethernet_server_dark.svg | 102 ++++++++++++++++++++++ .github/readme/nethernet_server_light.mmd | 40 +++++++++ .github/readme/nethernet_server_light.svg | 102 ++++++++++++++++++++++ .github/readme/raknet_client_dark.mmd | 40 +++++++++ .github/readme/raknet_client_dark.svg | 102 ++++++++++++++++++++++ .github/readme/raknet_client_light.mmd | 40 +++++++++ .github/readme/raknet_client_light.svg | 102 ++++++++++++++++++++++ .github/readme/raknet_server_dark.mmd | 46 ++++++++++ .github/readme/raknet_server_dark.svg | 102 ++++++++++++++++++++++ .github/readme/raknet_server_light.mmd | 46 ++++++++++ .github/readme/raknet_server_light.svg | 102 ++++++++++++++++++++++ README.md | 24 ++--- transport-nethernet/README.md | 42 +++++++++ transport-raknet/README.md | 47 ++++++++++ 19 files changed, 1249 insertions(+), 18 deletions(-) create mode 100644 .github/readme/nethernet_client_dark.mmd create mode 100644 .github/readme/nethernet_client_dark.svg create mode 100644 .github/readme/nethernet_client_light.mmd create mode 100644 .github/readme/nethernet_client_light.svg create mode 100644 .github/readme/nethernet_server_dark.mmd create mode 100644 .github/readme/nethernet_server_dark.svg create mode 100644 .github/readme/nethernet_server_light.mmd create mode 100644 .github/readme/nethernet_server_light.svg create mode 100644 .github/readme/raknet_client_dark.mmd create mode 100644 .github/readme/raknet_client_dark.svg create mode 100644 .github/readme/raknet_client_light.mmd create mode 100644 .github/readme/raknet_client_light.svg create mode 100644 .github/readme/raknet_server_dark.mmd create mode 100644 .github/readme/raknet_server_dark.svg create mode 100644 .github/readme/raknet_server_light.mmd create mode 100644 .github/readme/raknet_server_light.svg create mode 100644 transport-nethernet/README.md diff --git a/.github/readme/nethernet_client_dark.mmd b/.github/readme/nethernet_client_dark.mmd new file mode 100644 index 0000000..e24840f --- /dev/null +++ b/.github/readme/nethernet_client_dark.mmd @@ -0,0 +1,40 @@ +--- +config: + layout: elk + theme: redux-dark +--- +flowchart LR + subgraph TransportLayer["NetherNet Transport"] + direction TB + Native["WebRTC Native
libdatachannel"] + DataChannel["RTCDataChannel
Reliability & Ordering"] + ClientChannel["NetherNetClientChannel
Netty Adapter"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User Handlers
Application Logic"] + end + + Network(("Network (DTLS/UDP)")) + Network == Encrypted Frame ==> Native + Native == Reassembled Message ==> DataChannel + DataChannel == RTCDataChannelBuffer ==> ClientChannel + ClientChannel == ByteBuf ==> UserLogic + UserLogic == ByteBuf ==> ClientChannel + ClientChannel == RTCDataChannelBuffer ==> DataChannel + DataChannel == Message ==> Native + Native == Encrypted Frame ==> Network + Native:::shared + DataChannel:::shared + ClientChannel:::shared + + classDef shared fill:#e67e22,stroke:#333,stroke-width:2px + + linkStyle 0 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 1 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 2 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 3 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + + linkStyle 4 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 5 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 6 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 7 stroke:#3498db,stroke-width:4px,color:#3498db \ No newline at end of file diff --git a/.github/readme/nethernet_client_dark.svg b/.github/readme/nethernet_client_dark.svg new file mode 100644 index 0000000..a706b4f --- /dev/null +++ b/.github/readme/nethernet_client_dark.svg @@ -0,0 +1,102 @@ +

User ChannelPipeline

NetherNet Transport

User Handlers
Application Logic

WebRTC Native
libdatachannel

RTCDataChannel
Reliability & Ordering

NetherNetClientChannel
Netty Adapter

Network (DTLS/UDP)

Encrypted Frame

Reassembled Message

RTCDataChannelBuffer

ByteBuf

ByteBuf

RTCDataChannelBuffer

Message

Encrypted Frame

\ No newline at end of file diff --git a/.github/readme/nethernet_client_light.mmd b/.github/readme/nethernet_client_light.mmd new file mode 100644 index 0000000..1f7b693 --- /dev/null +++ b/.github/readme/nethernet_client_light.mmd @@ -0,0 +1,40 @@ +--- +config: + layout: elk + theme: redux +--- +flowchart LR + subgraph TransportLayer["NetherNet Transport"] + direction TB + Native["WebRTC Native
libdatachannel"] + DataChannel["RTCDataChannel
Reliability & Ordering"] + ClientChannel["NetherNetClientChannel
Netty Adapter"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User Handlers
Application Logic"] + end + + Network(("Network (DTLS/UDP)")) + Network == Encrypted Frame ==> Native + Native == Reassembled Message ==> DataChannel + DataChannel == RTCDataChannelBuffer ==> ClientChannel + ClientChannel == ByteBuf ==> UserLogic + UserLogic == ByteBuf ==> ClientChannel + ClientChannel == RTCDataChannelBuffer ==> DataChannel + DataChannel == Message ==> Native + Native == Encrypted Frame ==> Network + Native:::shared + DataChannel:::shared + ClientChannel:::shared + + classDef shared fill:#e67e22,stroke:#333,stroke-width:2px + + linkStyle 0 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 1 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 2 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 3 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + + linkStyle 4 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 5 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 6 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 7 stroke:#3498db,stroke-width:4px,color:#3498db \ No newline at end of file diff --git a/.github/readme/nethernet_client_light.svg b/.github/readme/nethernet_client_light.svg new file mode 100644 index 0000000..1865ad5 --- /dev/null +++ b/.github/readme/nethernet_client_light.svg @@ -0,0 +1,102 @@ +

User ChannelPipeline

NetherNet Transport

User Handlers
Application Logic

WebRTC Native
libdatachannel

RTCDataChannel
Reliability & Ordering

NetherNetClientChannel
Netty Adapter

Network (DTLS/UDP)

Encrypted Frame

Reassembled Message

RTCDataChannelBuffer

ByteBuf

ByteBuf

RTCDataChannelBuffer

Message

Encrypted Frame

\ No newline at end of file diff --git a/.github/readme/nethernet_server_dark.mmd b/.github/readme/nethernet_server_dark.mmd new file mode 100644 index 0000000..969248d --- /dev/null +++ b/.github/readme/nethernet_server_dark.mmd @@ -0,0 +1,46 @@ +--- +config: + layout: elk + theme: redux-dark +--- +flowchart LR + subgraph TransportLayer["NetherNet Transport"] + direction TB + Native["WebRTC Native
libdatachannel"] + DataChannel["RTCDataChannel
Reliability & Ordering"] + ChildChannel["NetherNetChildChannel
Netty Adapter"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User Handlers
Application Logic"] + end + + Network(("Network (DTLS/UDP)")) + + %% Inbound + Network == Encrypted Frame ==> Native + Native == Reassembled Message ==> DataChannel + DataChannel == RTCDataChannelBuffer ==> ChildChannel + ChildChannel == ByteBuf ==> UserLogic + + %% Outbound + UserLogic == ByteBuf ==> ChildChannel + ChildChannel == RTCDataChannelBuffer ==> DataChannel + DataChannel == Message ==> Native + Native == Encrypted Frame ==> Network + + %% Styling + Native:::shared + DataChannel:::shared + ChildChannel:::shared + + classDef shared fill:#e67e22,stroke:#333,stroke-width:2px + + linkStyle 0 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 1 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 2 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 3 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + + linkStyle 4 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 5 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 6 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 7 stroke:#3498db,stroke-width:4px,color:#3498db \ No newline at end of file diff --git a/.github/readme/nethernet_server_dark.svg b/.github/readme/nethernet_server_dark.svg new file mode 100644 index 0000000..ea730b8 --- /dev/null +++ b/.github/readme/nethernet_server_dark.svg @@ -0,0 +1,102 @@ +

User ChannelPipeline

NetherNet Transport

User Handlers
Application Logic

WebRTC Native
libdatachannel

RTCDataChannel
Reliability & Ordering

NetherNetChildChannel
Netty Adapter

Network (DTLS/UDP)

Encrypted Frame

Reassembled Message

RTCDataChannelBuffer

ByteBuf

ByteBuf

RTCDataChannelBuffer

Message

Encrypted Frame

\ No newline at end of file diff --git a/.github/readme/nethernet_server_light.mmd b/.github/readme/nethernet_server_light.mmd new file mode 100644 index 0000000..44b10fd --- /dev/null +++ b/.github/readme/nethernet_server_light.mmd @@ -0,0 +1,40 @@ +--- +config: + layout: elk + theme: redux +--- +flowchart LR + subgraph TransportLayer["NetherNet Transport"] + direction TB + Native["WebRTC Native
libdatachannel"] + DataChannel["RTCDataChannel
Reliability & Ordering"] + ChildChannel["NetherNetChildChannel
Netty Adapter"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User Handlers
Application Logic"] + end + + Network(("Network (DTLS/UDP)")) + Network == Encrypted Frame ==> Native + Native == Reassembled Message ==> DataChannel + DataChannel == RTCDataChannelBuffer ==> ChildChannel + ChildChannel == ByteBuf ==> UserLogic + UserLogic == ByteBuf ==> ChildChannel + ChildChannel == RTCDataChannelBuffer ==> DataChannel + DataChannel == Message ==> Native + Native == Encrypted Frame ==> Network + Native:::shared + DataChannel:::shared + ChildChannel:::shared + + classDef shared fill:#e67e22,stroke:#333,stroke-width:2px + + linkStyle 0 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 1 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 2 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + linkStyle 3 stroke:#2ecc71,stroke-width:4px,color:#2ecc71 + + linkStyle 4 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 5 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 6 stroke:#3498db,stroke-width:4px,color:#3498db + linkStyle 7 stroke:#3498db,stroke-width:4px,color:#3498db \ No newline at end of file diff --git a/.github/readme/nethernet_server_light.svg b/.github/readme/nethernet_server_light.svg new file mode 100644 index 0000000..bb9bae8 --- /dev/null +++ b/.github/readme/nethernet_server_light.svg @@ -0,0 +1,102 @@ +

User ChannelPipeline

NetherNet Transport

User Handlers
Application Logic

WebRTC Native
libdatachannel

RTCDataChannel
Reliability & Ordering

NetherNetChildChannel
Netty Adapter

Network (DTLS/UDP)

Encrypted Frame

Reassembled Message

RTCDataChannelBuffer

ByteBuf

ByteBuf

RTCDataChannelBuffer

Message

Encrypted Frame

\ No newline at end of file diff --git a/.github/readme/raknet_client_dark.mmd b/.github/readme/raknet_client_dark.mmd new file mode 100644 index 0000000..235eb50 --- /dev/null +++ b/.github/readme/raknet_client_dark.mmd @@ -0,0 +1,40 @@ +--- +config: + layout: elk + theme: redux-dark +--- +flowchart LR + subgraph InternalPipeline["RakNet Internal Pipeline
(Parent DatagramChannel)"] + direction TB + ProxyRoute["RakClientProxyRouteHandler
Routes to/from Client Channel"] + DatagramCodec["RakDatagramCodec
Encodes/Decodes
RakNet Datagrams
"] + AckHandler["RakAcknowledgeHandler
Manages ACKs/NACKs"] + SessionCodec["RakSessionCodec
Reliability, Ordering,
Split Packets
"] + end + subgraph UserPipeline["User ChannelPipeline
(RakClientChannel)"] + UserLogic["User Handlers
Application Logic"] + end + Network(("Network Socket")) == Inbound UDP Datagram ==> DatagramCodec + DatagramCodec == EncapsulatedPacket ==> AckHandler + AckHandler == EncapsulatedPacket ==> SessionCodec & DatagramCodec + SessionCodec == ByteBuf ==> ProxyRoute + ProxyRoute == ByteBuf ==> UserLogic & SessionCodec + UserLogic == ByteBuf ==> ProxyRoute + SessionCodec == EncapsulatedPacket ==> AckHandler + DatagramCodec == Outbound UDP Datagram ==> Network + + ProxyRoute:::shared + DatagramCodec:::shared + AckHandler:::shared + SessionCodec:::shared + classDef shared fill:#9b59b6,stroke:#333,stroke-width:2px + linkStyle 0 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 1 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 2 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 3 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 4 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 5 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 6 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 7 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 8 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 9 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none \ No newline at end of file diff --git a/.github/readme/raknet_client_dark.svg b/.github/readme/raknet_client_dark.svg new file mode 100644 index 0000000..0aee4b6 --- /dev/null +++ b/.github/readme/raknet_client_dark.svg @@ -0,0 +1,102 @@ +

User ChannelPipeline
(RakClientChannel)

RakNet Internal Pipeline
(Parent DatagramChannel)

User Handlers
Application Logic

RakClientProxyRouteHandler
Routes to/from Client Channel

RakDatagramCodec
Encodes/Decodes
RakNet Datagrams

RakAcknowledgeHandler
Manages ACKs/NACKs

RakSessionCodec
Reliability, Ordering,
Split Packets

Network Socket

Inbound UDP Datagram

EncapsulatedPacket

EncapsulatedPacket

EncapsulatedPacket

ByteBuf

ByteBuf

ByteBuf

ByteBuf

EncapsulatedPacket

Outbound UDP Datagram

\ No newline at end of file diff --git a/.github/readme/raknet_client_light.mmd b/.github/readme/raknet_client_light.mmd new file mode 100644 index 0000000..49fbaa9 --- /dev/null +++ b/.github/readme/raknet_client_light.mmd @@ -0,0 +1,40 @@ +--- +config: + layout: elk + theme: redux +--- +flowchart LR + subgraph InternalPipeline["RakNet Internal Pipeline
(Parent DatagramChannel)"] + direction TB + ProxyRoute["RakClientProxyRouteHandler
Routes to/from Client Channel"] + DatagramCodec["RakDatagramCodec
Encodes/Decodes
RakNet Datagrams
"] + AckHandler["RakAcknowledgeHandler
Manages ACKs/NACKs"] + SessionCodec["RakSessionCodec
Reliability, Ordering,
Split Packets
"] + end + subgraph UserPipeline["User ChannelPipeline
(RakClientChannel)"] + UserLogic["User Handlers
Application Logic"] + end + Network(("Network Socket")) == Inbound UDP Datagram ==> DatagramCodec + DatagramCodec == EncapsulatedPacket ==> AckHandler + AckHandler == EncapsulatedPacket ==> SessionCodec & DatagramCodec + SessionCodec == ByteBuf ==> ProxyRoute + ProxyRoute == ByteBuf ==> UserLogic & SessionCodec + UserLogic == ByteBuf ==> ProxyRoute + SessionCodec == EncapsulatedPacket ==> AckHandler + DatagramCodec == Outbound UDP Datagram ==> Network + + ProxyRoute:::shared + DatagramCodec:::shared + AckHandler:::shared + SessionCodec:::shared + classDef shared fill:#9b59b6,stroke:#333,stroke-width:2px + linkStyle 0 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 1 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 2 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 3 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 4 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 5 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 6 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 7 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 8 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 9 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none \ No newline at end of file diff --git a/.github/readme/raknet_client_light.svg b/.github/readme/raknet_client_light.svg new file mode 100644 index 0000000..e3db5af --- /dev/null +++ b/.github/readme/raknet_client_light.svg @@ -0,0 +1,102 @@ +

User ChannelPipeline
(RakClientChannel)

RakNet Internal Pipeline
(Parent DatagramChannel)

User Handlers
Application Logic

RakClientProxyRouteHandler
Routes to/from Client Channel

RakDatagramCodec
Encodes/Decodes
RakNet Datagrams

RakAcknowledgeHandler
Manages ACKs/NACKs

RakSessionCodec
Reliability, Ordering,
Split Packets

Network Socket

Inbound UDP Datagram

EncapsulatedPacket

EncapsulatedPacket

EncapsulatedPacket

ByteBuf

ByteBuf

ByteBuf

ByteBuf

EncapsulatedPacket

Outbound UDP Datagram

\ No newline at end of file diff --git a/.github/readme/raknet_server_dark.mmd b/.github/readme/raknet_server_dark.mmd new file mode 100644 index 0000000..96f83ff --- /dev/null +++ b/.github/readme/raknet_server_dark.mmd @@ -0,0 +1,46 @@ +--- +config: + layout: elk + theme: redux-dark +--- +flowchart RL + subgraph InternalPipeline["RakNet Internal Pipeline
(RakChildChannel)"] + direction TB + ChildDatagramHandler["RakChildDatagramHandler
Bridge to Parent Channel"] + DatagramCodec["RakDatagramCodec
Encodes/Decodes
RakNet Datagrams
"] + AckHandler["RakAcknowledgeHandler
Manages ACKs/NACKs"] + SessionCodec["RakSessionCodec
Reliability, Ordering,
Split Packets
"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User Handlers
Application Logic
(e.g. Bedrock Protocol)
"] + end + Network(("Network Socket")) == Inbound UDP Datagram ==> Parent["RakServerChannel
UDP Bind & Routing"] + Parent == Routed DatagramPacket ==> ChildDatagramHandler + ChildDatagramHandler == DatagramPacket ==> DatagramCodec & Parent + DatagramCodec == EncapsulatedPacket ==> AckHandler + AckHandler == EncapsulatedPacket ==> SessionCodec & DatagramCodec + SessionCodec == ByteBuf ==> UserLogic + UserLogic == ByteBuf ==> SessionCodec + SessionCodec == EncapsulatedPacket ==> AckHandler + DatagramCodec == DatagramPacket ==> ChildDatagramHandler + Parent == Outbound UDP Datagram ==> Network + + ChildDatagramHandler:::shared + DatagramCodec:::shared + AckHandler:::shared + SessionCodec:::shared + classDef shared fill:#9b59b6,stroke:#333,stroke-width:2px + classDef incoming fill:#2ecc71,stroke:#333,stroke-width:2px + classDef outgoing fill:#3498db,stroke:#333,stroke-width:2px + linkStyle 0 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 1 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 2 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 3 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 4 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 5 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 6 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 7 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 8 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 9 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 10 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 11 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none \ No newline at end of file diff --git a/.github/readme/raknet_server_dark.svg b/.github/readme/raknet_server_dark.svg new file mode 100644 index 0000000..ec8419d --- /dev/null +++ b/.github/readme/raknet_server_dark.svg @@ -0,0 +1,102 @@ +

User ChannelPipeline

RakNet Internal Pipeline
(RakChildChannel)

User Handlers
Application Logic
(e.g. Bedrock Protocol)

RakChildDatagramHandler
Bridge to Parent Channel

RakDatagramCodec
Encodes/Decodes
RakNet Datagrams

RakAcknowledgeHandler
Manages ACKs/NACKs

RakSessionCodec
Reliability, Ordering,
Split Packets

Network Socket

RakServerChannel
UDP Bind & Routing

Inbound UDP Datagram

Routed DatagramPacket

DatagramPacket

DatagramPacket

EncapsulatedPacket

EncapsulatedPacket

EncapsulatedPacket

ByteBuf

ByteBuf

EncapsulatedPacket

DatagramPacket

Outbound UDP Datagram

\ No newline at end of file diff --git a/.github/readme/raknet_server_light.mmd b/.github/readme/raknet_server_light.mmd new file mode 100644 index 0000000..e2c4aeb --- /dev/null +++ b/.github/readme/raknet_server_light.mmd @@ -0,0 +1,46 @@ +--- +config: + layout: elk + theme: redux +--- +flowchart RL + subgraph InternalPipeline["RakNet Internal Pipeline
(RakChildChannel)"] + direction TB + ChildDatagramHandler["RakChildDatagramHandler
Bridge to Parent Channel"] + DatagramCodec["RakDatagramCodec
Encodes/Decodes
RakNet Datagrams
"] + AckHandler["RakAcknowledgeHandler
Manages ACKs/NACKs"] + SessionCodec["RakSessionCodec
Reliability, Ordering,
Split Packets
"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User Handlers
Application Logic
(e.g. Bedrock Protocol)
"] + end + Network(("Network Socket")) == Inbound UDP Datagram ==> Parent["RakServerChannel
UDP Bind & Routing"] + Parent == Routed DatagramPacket ==> ChildDatagramHandler + ChildDatagramHandler == DatagramPacket ==> DatagramCodec & Parent + DatagramCodec == EncapsulatedPacket ==> AckHandler + AckHandler == EncapsulatedPacket ==> SessionCodec & DatagramCodec + SessionCodec == ByteBuf ==> UserLogic + UserLogic == ByteBuf ==> SessionCodec + SessionCodec == EncapsulatedPacket ==> AckHandler + DatagramCodec == DatagramPacket ==> ChildDatagramHandler + Parent == Outbound UDP Datagram ==> Network + + ChildDatagramHandler:::shared + DatagramCodec:::shared + AckHandler:::shared + SessionCodec:::shared + classDef shared fill:#9b59b6,stroke:#333,stroke-width:2px + classDef incoming fill:#2ecc71,stroke:#333,stroke-width:2px + classDef outgoing fill:#3498db,stroke:#333,stroke-width:2px + linkStyle 0 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 1 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 2 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 3 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 4 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 5 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 6 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 7 stroke:#2ecc71,stroke-width:4px,color:#2ecc71,fill:none + linkStyle 8 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 9 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 10 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none + linkStyle 11 stroke:#3498db,stroke-width:4px,color:#3498db,fill:none \ No newline at end of file diff --git a/.github/readme/raknet_server_light.svg b/.github/readme/raknet_server_light.svg new file mode 100644 index 0000000..6a8d4cc --- /dev/null +++ b/.github/readme/raknet_server_light.svg @@ -0,0 +1,102 @@ +

User ChannelPipeline

RakNet Internal Pipeline
(RakChildChannel)

User Handlers
Application Logic
(e.g. Bedrock Protocol)

RakChildDatagramHandler
Bridge to Parent Channel

RakDatagramCodec
Encodes/Decodes
RakNet Datagrams

RakAcknowledgeHandler
Manages ACKs/NACKs

RakSessionCodec
Reliability, Ordering,
Split Packets

Network Socket

RakServerChannel
UDP Bind & Routing

Inbound UDP Datagram

Routed DatagramPacket

DatagramPacket

DatagramPacket

EncapsulatedPacket

EncapsulatedPacket

EncapsulatedPacket

ByteBuf

ByteBuf

EncapsulatedPacket

DatagramPacket

Outbound UDP Datagram

\ No newline at end of file diff --git a/README.md b/README.md index 7e70a7e..486b242 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,13 @@ ## Introduction -You can join the [Discord](https://discord.gg/5z4GuSnqmQ) for help with this fork. This is a fork of [CloudburstMC/Network](https://github.com/CloudburstMC/Network) with a focus on improving the compatibility of the client side of the library to more closely align with the vanilla Minecraft Bedrock client. +You can join the [Discord](https://discord.gg/5z4GuSnqmQ) for help with this fork. This raknet portion of this library is a fork of [CloudburstMC/Network](https://github.com/CloudburstMC/Network) with a focus on improving the compatibility of the client side of the library to more closely align with the vanilla Minecraft Bedrock client. -## Changes from Original Library +The new package `netty-transport-nethernet` is also included, which provides support for the Nethernet protocol. This is achieved using a JNI wrapper for the native WebRTC library. -- New incoming connection batches additional packets to more closely imitate the vanilla client (from [@RaphiMC](https://github.com/RaphiMC)): - - A `Connected Ping` - - The first game packet, `Request Network Settings Packet` -- Allows for resetting security state if `Open Connection Reply 1` is resent by the server -- Only do retries with `Open Connection Request 1`, and reserve `Open Connection Request 2` only as a direct response to `Open Connection Reply 1` -- Allows using datagram channel factories for raknet (from [@AlexProgrammerDE](https://github.com/AlexProgrammerDE)) -- Skips over improperly typed client address fields -- Does not set RakNet flag `NEEDS_B_AND_AS` on client messages +## Package Specific Information -## Usage +See the respective README files for each transport library for more information: -### Releases ![Maven Central Version](https://img.shields.io/maven-central/v/dev.kastle.netty/netty-transport-raknet?label=Maven%20Central&color=%233fb950) - -The library is published to Maven Central. See the [latest release](https://github.com/Kas-tle/NetworkCompatible/releases/latest) for the latest version. - -### Snapshots [![](https://jitpack.io/v/dev.kastle/NetworkCompatible.svg)](https://jitpack.io/#dev.kastle/NetworkCompatible) - -Snapshots are available from [jitpack](https://jitpack.io/#dev.kastle/NetworkCompatible). Note the package group for jitpack is `dev.kastle.NetworkCompatible` witht the name `netty-transport-raknet`. +- [netty-transport-raknet](transport-raknet/README.md) +- [netty-transport-nethernet](transport-nethernet/README.md) \ No newline at end of file diff --git a/transport-nethernet/README.md b/transport-nethernet/README.md new file mode 100644 index 0000000..33ac093 --- /dev/null +++ b/transport-nethernet/README.md @@ -0,0 +1,42 @@ +# netty-transport-nethernet + +## Downloads + +### Releases ![Maven Central Version](https://img.shields.io/maven-central/v/dev.kastle.netty/netty-transport-nethernet?label=Maven%20Central&color=%233fb950) + +The library is published to Maven Central. See the [latest release](https://github.com/Kas-tle/NetworkCompatible/releases/latest) for the latest version. + +### Snapshots [![](https://jitpack.io/v/dev.kastle/NetworkCompatible.svg)](https://jitpack.io/#dev.kastle/NetworkCompatible) + +Snapshots are available from [jitpack](https://jitpack.io/#dev.kastle/NetworkCompatible). Note the package group for jitpack is `dev.kastle.NetworkCompatible` witht the name `netty-transport-nethernet`. + +## Usage + +### Natives + +This library requires the platform-specific WebRTC native libraries at runtime. See [Kas-tle/webrtc-java](https://github.com/Kas-tle/webrtc-java?tab=readme-ov-file#usage) for instructions on how to include the native libraries in your project. + +### Examples + +These projects use this library to provide Nethernet support. You can see their source code for examples of how to use this library: + +- [Kas-tle/ProxyPass](https://github.com/Kas-tle/ProxyPass): Uses server and client to debug game packets over various connection types. +- [MCXboxBroadcast/Broadcaster](https://github.com/MCXboxBroadcast/Broadcaster): Uses server to allow Bedrock clients to transfer to other Bedrock servers via Xbox Live. +- [ViaVersion/ViaFabricPlus](https://github.com/ViaVersion/ViaFabricPlus): Uses client to connect to LAN games and Realms. +- [ViaVersion/ViaProxy](https://github.com/ViaVersion/ViaProxy): Uses client to connect to LAN games and Realms. + +## Packet Flow + +### Client + + + + + + +### Server + + + + + \ No newline at end of file diff --git a/transport-raknet/README.md b/transport-raknet/README.md index 4937467..697ca90 100644 --- a/transport-raknet/README.md +++ b/transport-raknet/README.md @@ -1 +1,48 @@ # netty-transport-raknet + +## Changes from Original Library + +- New incoming connection batches additional packets to more closely imitate the vanilla client (from [@RaphiMC](https://github.com/RaphiMC)): + - A `Connected Ping` + - The first game packet, `Request Network Settings Packet` +- Allows for resetting security state if `Open Connection Reply 1` is resent by the server +- Only do retries with `Open Connection Request 1`, and reserve `Open Connection Request 2` only as a direct response to `Open Connection Reply 1` +- Allows using datagram channel factories for raknet (from [@AlexProgrammerDE](https://github.com/AlexProgrammerDE)) +- Skips over improperly typed client address fields +- Does not set RakNet flag `NEEDS_B_AND_AS` on client messages + +## Downloads + +### Releases ![Maven Central Version](https://img.shields.io/maven-central/v/dev.kastle.netty/netty-transport-raknet?label=Maven%20Central&color=%233fb950) + +The library is published to Maven Central. See the [latest release](https://github.com/Kas-tle/NetworkCompatible/releases/latest) for the latest version. + +### Snapshots [![](https://jitpack.io/v/dev.kastle/NetworkCompatible.svg)](https://jitpack.io/#dev.kastle/NetworkCompatible) + +Snapshots are available from [jitpack](https://jitpack.io/#dev.kastle/NetworkCompatible). Note the package group for jitpack is `dev.kastle.NetworkCompatible` witht the name `netty-transport-raknet`. + +## Usage + +### Examples + +These projects use this library to provide Raknet support. You can see their source code for examples of how to use this library: + +- [Kas-tle/ProxyPass](https://github.com/Kas-tle/ProxyPass): Uses server and client to debug game packets over various connection types. +- [ViaVersion/ViaFabricPlus](https://github.com/ViaVersion/ViaFabricPlus): Uses client to connect to Bedrock servers. +- [ViaVersion/ViaProxy](https://github.com/ViaVersion/ViaProxy): Uses client to connect to Bedrock servers. + +## Packet Flow + +### Client + + + + + + +### Server + + + + + \ No newline at end of file From 0c642a56accab7f4512f4e0cdf0c52abc9911a7e Mon Sep 17 00:00:00 2001 From: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:53:17 -0800 Subject: [PATCH 22/22] Small README tweaks Signed-off-by: Joshua Castle <26531652+Kas-tle@users.noreply.github.com> --- transport-nethernet/README.md | 9 ++++++--- transport-raknet/README.md | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/transport-nethernet/README.md b/transport-nethernet/README.md index 33ac093..788e5b6 100644 --- a/transport-nethernet/README.md +++ b/transport-nethernet/README.md @@ -12,9 +12,8 @@ Snapshots are available from [jitpack](https://jitpack.io/#dev.kastle/NetworkCom ## Usage -### Natives - -This library requires the platform-specific WebRTC native libraries at runtime. See [Kas-tle/webrtc-java](https://github.com/Kas-tle/webrtc-java?tab=readme-ov-file#usage) for instructions on how to include the native libraries in your project. +> [!IMPORTANT] +> This library requires the platform-specific WebRTC native libraries at runtime. See [Kas-tle/webrtc-java](https://github.com/Kas-tle/webrtc-java?tab=readme-ov-file#usage) for instructions on how to include the native libraries in your project. ### Examples @@ -29,6 +28,8 @@ These projects use this library to provide Nethernet support. You can see their ### Client +--- + @@ -36,6 +37,8 @@ These projects use this library to provide Nethernet support. You can see their ### Server +--- + diff --git a/transport-raknet/README.md b/transport-raknet/README.md index 697ca90..a346874 100644 --- a/transport-raknet/README.md +++ b/transport-raknet/README.md @@ -35,6 +35,8 @@ These projects use this library to provide Raknet support. You can see their sou ### Client +--- + @@ -42,6 +44,8 @@ These projects use this library to provide Raknet support. You can see their sou ### Server +--- +