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 Nativelibdatachannel"] + DataChannel["RTCDataChannelReliability & Ordering"] + ClientChannel["NetherNetClientChannelNetty Adapter"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User HandlersApplication 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 ChannelPipelineNetherNet TransportUser HandlersApplication LogicWebRTC NativelibdatachannelRTCDataChannelReliability & OrderingNetherNetClientChannelNetty AdapterNetwork (DTLS/UDP)Encrypted FrameReassembled MessageRTCDataChannelBufferByteBufByteBufRTCDataChannelBufferMessageEncrypted 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 Nativelibdatachannel"] + DataChannel["RTCDataChannelReliability & Ordering"] + ClientChannel["NetherNetClientChannelNetty Adapter"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User HandlersApplication 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 ChannelPipelineNetherNet TransportUser HandlersApplication LogicWebRTC NativelibdatachannelRTCDataChannelReliability & OrderingNetherNetClientChannelNetty AdapterNetwork (DTLS/UDP)Encrypted FrameReassembled MessageRTCDataChannelBufferByteBufByteBufRTCDataChannelBufferMessageEncrypted 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 Nativelibdatachannel"] + DataChannel["RTCDataChannelReliability & Ordering"] + ChildChannel["NetherNetChildChannelNetty Adapter"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User HandlersApplication 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 ChannelPipelineNetherNet TransportUser HandlersApplication LogicWebRTC NativelibdatachannelRTCDataChannelReliability & OrderingNetherNetChildChannelNetty AdapterNetwork (DTLS/UDP)Encrypted FrameReassembled MessageRTCDataChannelBufferByteBufByteBufRTCDataChannelBufferMessageEncrypted 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 Nativelibdatachannel"] + DataChannel["RTCDataChannelReliability & Ordering"] + ChildChannel["NetherNetChildChannelNetty Adapter"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User HandlersApplication 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 ChannelPipelineNetherNet TransportUser HandlersApplication LogicWebRTC NativelibdatachannelRTCDataChannelReliability & OrderingNetherNetChildChannelNetty AdapterNetwork (DTLS/UDP)Encrypted FrameReassembled MessageRTCDataChannelBufferByteBufByteBufRTCDataChannelBufferMessageEncrypted 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["RakClientProxyRouteHandlerRoutes to/from Client Channel"] + DatagramCodec["RakDatagramCodecEncodes/DecodesRakNet Datagrams"] + AckHandler["RakAcknowledgeHandlerManages ACKs/NACKs"] + SessionCodec["RakSessionCodecReliability, Ordering,Split Packets"] + end + subgraph UserPipeline["User ChannelPipeline(RakClientChannel)"] + UserLogic["User HandlersApplication 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 HandlersApplication LogicRakClientProxyRouteHandlerRoutes to/from Client ChannelRakDatagramCodecEncodes/DecodesRakNet DatagramsRakAcknowledgeHandlerManages ACKs/NACKsRakSessionCodecReliability, Ordering,Split PacketsNetwork SocketInbound UDP DatagramEncapsulatedPacketEncapsulatedPacketEncapsulatedPacketByteBufByteBufByteBufByteBufEncapsulatedPacketOutbound 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["RakClientProxyRouteHandlerRoutes to/from Client Channel"] + DatagramCodec["RakDatagramCodecEncodes/DecodesRakNet Datagrams"] + AckHandler["RakAcknowledgeHandlerManages ACKs/NACKs"] + SessionCodec["RakSessionCodecReliability, Ordering,Split Packets"] + end + subgraph UserPipeline["User ChannelPipeline(RakClientChannel)"] + UserLogic["User HandlersApplication 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 HandlersApplication LogicRakClientProxyRouteHandlerRoutes to/from Client ChannelRakDatagramCodecEncodes/DecodesRakNet DatagramsRakAcknowledgeHandlerManages ACKs/NACKsRakSessionCodecReliability, Ordering,Split PacketsNetwork SocketInbound UDP DatagramEncapsulatedPacketEncapsulatedPacketEncapsulatedPacketByteBufByteBufByteBufByteBufEncapsulatedPacketOutbound 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["RakChildDatagramHandlerBridge to Parent Channel"] + DatagramCodec["RakDatagramCodecEncodes/DecodesRakNet Datagrams"] + AckHandler["RakAcknowledgeHandlerManages ACKs/NACKs"] + SessionCodec["RakSessionCodecReliability, Ordering,Split Packets"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User HandlersApplication Logic(e.g. Bedrock Protocol)"] + end + Network(("Network Socket")) == Inbound UDP Datagram ==> Parent["RakServerChannelUDP 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 ChannelPipelineRakNet Internal Pipeline(RakChildChannel)User HandlersApplication Logic(e.g. Bedrock Protocol)RakChildDatagramHandlerBridge to Parent ChannelRakDatagramCodecEncodes/DecodesRakNet DatagramsRakAcknowledgeHandlerManages ACKs/NACKsRakSessionCodecReliability, Ordering,Split PacketsNetwork SocketRakServerChannelUDP Bind & RoutingInbound UDP DatagramRouted DatagramPacketDatagramPacketDatagramPacketEncapsulatedPacketEncapsulatedPacketEncapsulatedPacketByteBufByteBufEncapsulatedPacketDatagramPacketOutbound 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["RakChildDatagramHandlerBridge to Parent Channel"] + DatagramCodec["RakDatagramCodecEncodes/DecodesRakNet Datagrams"] + AckHandler["RakAcknowledgeHandlerManages ACKs/NACKs"] + SessionCodec["RakSessionCodecReliability, Ordering,Split Packets"] + end + subgraph UserPipeline["User ChannelPipeline"] + UserLogic["User HandlersApplication Logic(e.g. Bedrock Protocol)"] + end + Network(("Network Socket")) == Inbound UDP Datagram ==> Parent["RakServerChannelUDP 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 ChannelPipelineRakNet Internal Pipeline(RakChildChannel)User HandlersApplication Logic(e.g. Bedrock Protocol)RakChildDatagramHandlerBridge to Parent ChannelRakDatagramCodecEncodes/DecodesRakNet DatagramsRakAcknowledgeHandlerManages ACKs/NACKsRakSessionCodecReliability, Ordering,Split PacketsNetwork SocketRakServerChannelUDP Bind & RoutingInbound UDP DatagramRouted DatagramPacketDatagramPacketDatagramPacketEncapsulatedPacketEncapsulatedPacketEncapsulatedPacketByteBufByteBufEncapsulatedPacketDatagramPacketOutbound UDP Datagram \ No newline at end of file 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/.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/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  - -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/#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/build.gradle.kts b/build.gradle.kts index 716d963..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,20 +102,23 @@ subprojects { useJUnitPlatform() } } +} - nmcp { - publishAllPublications {} +dependencies { + allprojects { + nmcpAggregation(project(path)) } } -nmcp { - publishAggregation { +nmcpAggregation { + centralPortal { project(":transport-raknet") + project(":transport-nethernet") - username.set(System.getenv("MAVEN_CENTRAL_USERNAME") ?: "username") - password.set(System.getenv("MAVEN_CENTRAL_PASSWORD") ?: "password") - - 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 f136bf5..fa1d205 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,20 @@ [versions] -netty = "4.1.101.Final" +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" } +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" } - -expiringmap = { group = "net.jodah", name = "expiringmap", version = "0.5.10" } +webrtc-java = { group = "dev.kastle.webrtc", name = "webrtc-java", version.ref = "webrtc-java" } # Test dependencies junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } @@ -25,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/settings.gradle.kts b/settings.gradle.kts index 6b2129c..61bab7c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,3 +21,4 @@ plugins { } include("transport-raknet") +include("transport-nethernet") \ No newline at end of file diff --git a/transport-nethernet/README.md b/transport-nethernet/README.md new file mode 100644 index 0000000..788e5b6 --- /dev/null +++ b/transport-nethernet/README.md @@ -0,0 +1,45 @@ +# netty-transport-nethernet + +## Downloads + +### Releases  + +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/#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 + +> [!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 + +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-nethernet/build.gradle.kts b/transport-nethernet/build.gradle.kts new file mode 100644 index 0000000..76eef36 --- /dev/null +++ b/transport-nethernet/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("com.gradleup.nmcp") +} + +description = "NetherNet transport for Netty" + +dependencies { + api(libs.bundles.netty) + api(libs.netty.codec.http) + api(libs.expiringmap) + api(libs.webrtc.java) + + implementation(libs.gson) + + testImplementation(libs.bundles.junit) + 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" +} + +tasks.register("runDiscovery") { + 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 new file mode 100644 index 0000000..3318d4d --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannel.java @@ -0,0 +1,273 @@ +package dev.kastle.netty.channel.nethernet; + +import dev.kastle.netty.channel.nethernet.config.DefaultNetherChannelConfig; +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; +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 DefaultNetherChannelConfig config; + protected volatile RTCPeerConnection peerConnection; + protected volatile SocketAddress remoteAddress; + protected volatile SocketAddress 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; + } + + 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..82c3cc0 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChannelFactory.java @@ -0,0 +1,45 @@ +package dev.kastle.netty.channel.nethernet; + +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; + +import java.util.function.Supplier; + +public class NetherNetChannelFactory implements ChannelFactory { + + private final Supplier channelCreator; + + private NetherNetChannelFactory(Supplier channelCreator) { + this.channelCreator = channelCreator; + } + + @Override + 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)); + } +} \ 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..c324e4e --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetChildChannel.java @@ -0,0 +1,32 @@ +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; + +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; + this.config = new DefaultNetherChannelConfig(this); + } + + @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..31aa618 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetClientChannel.java @@ -0,0 +1,399 @@ +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; +import dev.kastle.webrtc.CreateSessionDescriptionObserver; +import dev.kastle.webrtc.PeerConnectionFactory; +import dev.kastle.webrtc.PeerConnectionObserver; +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.RTCIceServer; +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; +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; +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 NetherNetClientSignaling 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 volatile ScheduledFuture> handshakeTimeoutTask; + + private volatile String localUfrag; + + private int retryCount = 0; + + /** + * 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) { + this.targetNetworkId = id; + } + + @Override + public boolean isActive() { + return super.isActive() && handshakeComplete; + } + + @Override + protected void doClose() throws Exception { + super.doClose(); + if (handshakeTimeoutTask != null) { + handshakeTimeoutTask.cancel(false); + } + if (signaling != null) { + signaling.removeSignalHandler(this.connectionId); + signaling.close(); + } + if (connectPromise != null && !connectPromise.isDone()) { + connectPromise.tryFailure(new ClosedChannelException()); + } + } + + @Override + protected AbstractUnsafe newUnsafe() { + return new NetherNetClientUnsafe(); + } + + 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; + + 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; + } + + eventLoop().execute(() -> startHandshake()); + } + } + + private void startHandshake() { + if (!isOpen() || handshakeComplete) return; + + log.debug("Starting Handshake with Connection ID: {}", Long.toUnsignedString(this.connectionId)); + + if (handshakeTimeoutTask != null) handshakeTimeoutTask.cancel(false); + + signaling.setNotFoundHandler(reason -> { + 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(); + }, handshakeTimeout, TimeUnit.MILLISECONDS); + + signaling.setSignalHandler(this.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) { + 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 -> { + 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; + }); + } + + 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(); + peerConnection = null; + } + + signaling.removeSignalHandler(this.connectionId); + this.cycleConnectionId(); + startHandshake(); + } + + 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); + } + } + + 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; + } + + String sdp = candidate.sdp.trim(); + + // Format: ufrag network-id network-cost 0 + 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 + log.warn("PeerConnection entered FAILED state, resetting and retrying handshake."); + eventLoop().execute(() -> resetAndRetryHandshake()); + } else { + log.trace("PeerConnection state changed to {}", state); + } + } + + @Override public void onDataChannel(RTCDataChannel dataChannel) { } + }); + + setupDataChannels(); + } + + 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 ""; + } + + 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() { + 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 */ } + }); + } + @Override public void onFailure(String error) { /* Retry handled by timeout */ } + }); + } + + private void handleSignal(String signal) { + String[] parts = signal.split(" ", 3); + if (parts.length < 2) return; // Allow length 2 for ERROR packets without payload + String type = parts[0]; + 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.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.RTC_NEGOTIATION_CANDIDATE_ADD -> { + peerConnection.addIceCandidate(new RTCIceCandidate("0", 0, data)); + } + 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.")); + } + close(); + } + default -> { + log.debug("Received unknown signal type: {}", type); + } + } + }); + } + + 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.debug("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 onBufferedAmountChange(long previousAmount) {} + @Override public void onMessage(RTCDataChannelBuffer buffer) { + ReferenceCountUtil.release(buffer); + } + }); + } + + 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 new file mode 100644 index 0000000..abf5899 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetConstants.java @@ -0,0 +1,168 @@ +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; + + // 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; + 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); + } + } + + /** + * 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); + 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; + } + + /** + * 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"); + 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; + } + + /** + * 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 RTC_NEGOTIATION_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 RTC_NEGOTIATION_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 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 new file mode 100644 index 0000000..626b696 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java @@ -0,0 +1,297 @@ +package dev.kastle.netty.channel.nethernet; + +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.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.RTCIceServer; +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; +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 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 + protected void doBind(SocketAddress localAddress) throws Exception { + if (!(localAddress instanceof InetSocketAddress)) throw new IllegalArgumentException("Unsupported address type"); + this.localAddress = (InetSocketAddress) localAddress; + + this.signaling.setNewConnectionHandler((connectionId, remoteNetworkId, offerSdp) -> { + acceptConnection(connectionId, offerSdp, remoteNetworkId); + }); + + this.signaling.bind(localAddress); + } + + public void acceptConnection(long connectionId, String offerSdp, String remoteNetworkId) { + RTCConfiguration rtcConfig = new RTCConfiguration(); + rtcConfig.bundlePolicy = RTCBundlePolicy.MAX_BUNDLE; + + // Inject ICE servers if the signaling implementation supports it + 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); + } + } + + 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); + + 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()) { + 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) -> { + String[] parts = signal.split(" ", 3); + if (parts.length < 3) return; + String type = parts[0]; + String data = parts[2]; + + switch (type) { + case NetherNetConstants.RTC_NEGOTIATION_CANDIDATE_ADD -> { + log.trace("Applying Remote Candidate for {}: {}", Long.toUnsignedString(connectionId), 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)); + child.close(); + } + } + }); + + // Handle Offer + 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) + ); + pipeline().fireChannelRead(child); + } + @Override public void onFailure(String error) { log.error("SetLocalDesc failed: {}", error); } + }); + } + @Override public void onFailure(String error) { log.error("CreateAnswer failed: {}", 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; + + 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(); + } + + @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); + 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 + 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) { + if (handshakeTimeout != null) { + handshakeTimeout.cancel(false); + } + + 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 { + this.open = false; + + try { + signaling.close(); + } finally { + factory.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 this.open; + } + + @Override + public boolean isActive() { + return isOpen() && localAddress0() != null; + } + + @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/DefaultNetherChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.java new file mode 100644 index 0000000..d56b324 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherChannelConfig.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 DefaultNetherChannelConfig extends DefaultChannelConfig { + private final Map, Object> options = new ConcurrentHashMap<>(); + + public DefaultNetherChannelConfig(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/DefaultNetherClientChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java new file mode 100644 index 0000000..7c5456c --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherClientChannelConfig.java @@ -0,0 +1,59 @@ +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 = 3000; + private volatile int maxHandshakeAttempts = 3; + + public DefaultNetherClientChannelConfig(Channel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return this.getOptions( + super.getOptions(), + NetherChannelOption.NETHER_CLIENT_HANDSHAKE_TIMEOUT_MS, + NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS + ); + } + + @SuppressWarnings("unchecked") + @Override + 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); + } + + @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 if (option == NetherChannelOption.NETHER_CLIENT_MAX_HANDSHAKE_ATTEMPTS) { + this.setMaxHandshakeAttempts((Integer) value); + return true; + } else { + return super.setOption(option, 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/DefaultNetherServerChannelConfig.java b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/DefaultNetherServerChannelConfig.java new file mode 100644 index 0000000..f683068 --- /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 super.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 super.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..64c8a2a --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherChannelOption.java @@ -0,0 +1,29 @@ +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 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. + */ + 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 new file mode 100644 index 0000000..a64fb9c --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/config/NetherNetAddress.java @@ -0,0 +1,54 @@ +package dev.kastle.netty.channel.nethernet.config; + +import java.net.SocketAddress; + +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). + */ + 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 networkId; + } +} 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/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..6640e23 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetClientSignaling.java @@ -0,0 +1,34 @@ +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). + * + * @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(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 new file mode 100644 index 0000000..58ca7b2 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscovery.java @@ -0,0 +1,322 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +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; +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.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; + +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 peerAddresses = new ConcurrentHashMap<>(); + private Channel channel; + private byte[] pongData; + private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; + private BiConsumer 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; + } + + 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(PongData data) { + ByteBuf buf = Unpooled.buffer(); + buf.writeByte(4); // Version + writeString(buf, data.serverName()); + writeString(buf, data.levelName()); + buf.writeByte(data.gameType() << 1); + buf.writeIntLE(data.playerCount()); + buf.writeIntLE(data.maxPlayerCount()); + buf.writeBoolean(data.isEditorWorld()); + buf.writeBoolean(data.isHardcore()); + buf.writeByte(data.transportLayer() << 1); + buf.writeByte(data.connectionType() << 1); + byte[] binaryData = new byte[buf.readableBytes()]; + buf.readBytes(binaryData); + buf.release(); + + String hex = HexFormat.of().formatHex(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, SignalHandler handler) { + this.signalHandlers.put(connectionId, handler); + } + + public void unregisterSignalHandler(long connectionId) { + this.signalHandlers.remove(connectionId); + } + + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler 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); + } + + // 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 { + throw new IllegalArgumentException("Attempted to send signal to unknown peer: " + targetNetworkId); + } + } + + private void sendPacket(ByteBuf packetData, InetSocketAddress target) { + try { + byte[] encrypted = NetherNetConstants.encryptDiscoveryPacket(packetData); + channel.writeAndFlush(new DatagramPacket(Unpooled.wrappedBuffer(encrypted), target)); + } catch (Exception e) { + throw new RuntimeException("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; + } + + peerAddresses.put(senderId, packet.sender()); + + switch (packetId) { + case NetherNetConstants.ID_DISCOVERY_REQUEST -> { + log.trace("Handled discovery request from {}", packet.sender()); + handleRequest(senderId, packet.sender()); + } + 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); + } + 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()); + } + } + 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); + } 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 && 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; + + try { + String type = parts[0]; + long connectionId = Long.parseUnsignedLong(parts[1]); + + SignalHandler handler = signalHandlers.get(connectionId); + + if (handler != null) { + 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)); + 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) { + log.debug("Invalid connection ID format in message: {}", messageData); + } + } + + 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); + 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 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..16479d0 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetDiscoverySignaling.java @@ -0,0 +1,171 @@ +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; + +public class NetherNetDiscoverySignaling implements NetherNetClientSignaling, NetherNetServerSignaling { + 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); + + /** + * 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); + 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 (!this.discovery.isActive()) { + log.info("Binding NetherNet Discovery to {}", bindAddress); + this.discovery.bind(bindAddress); + } + + log.debug("Sending Discovery Request to {}", remote); + + // Send request and register the callback to capture the ID + this.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 bind(SocketAddress localAddress) { + if (!this.discovery.isActive()) { + if (localAddress instanceof InetSocketAddress) { + this.discovery.bind((InetSocketAddress) localAddress); + } else { + this.discovery.bind(bindAddress); + } + } + } + + @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 Network ID."); + return; + } + + try { + long id = Long.parseUnsignedLong(actualIdStr); + + // 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); + } + } + + @Override + public void setSignalHandler(long connectionId, SignalHandler handler) { + this.discovery.registerSignalHandler(connectionId, handler); + } + + @Override + public void removeSignalHandler(long connectionId) { + this.discovery.unregisterSignalHandler(connectionId); + } + + @Override + public void setNotFoundHandler(NetherNetClientSignaling.NotFoundHandler handler) { + // Not implemented for Discovery signaling + } + + @Override + public void 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..a15b53a --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetServerSignaling.java @@ -0,0 +1,130 @@ +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) throws ConnectException; + + /** + * 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). + * + * @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); + } + + /** + * 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. + * + * @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 new file mode 100644 index 0000000..fe63d3b --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetSignaling.java @@ -0,0 +1,88 @@ +package dev.kastle.netty.channel.nethernet.signaling; + +import java.util.List; + +public interface NetherNetSignaling extends AutoCloseable { + + /** + * 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); + + /** + * 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, 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); + + /** + * Returns the Local Network ID of this client as a String. + * This is required for formatting the 'candidate:' string in SDP. + */ + String getLocalNetworkId(); + + /** + * Closes the signaling channel and releases any associated resources. + */ + @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. + * + * @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 new file mode 100644 index 0000000..f2fc411 --- /dev/null +++ b/transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/signaling/NetherNetXboxSignaling.java @@ -0,0 +1,388 @@ +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 dev.kastle.netty.channel.nethernet.NetherNetConstants; +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.ChannelHandler.Sharable; +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.ConnectException; +import java.net.SocketAddress; +import java.net.URI; +import java.nio.channels.ClosedChannelException; +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; +import java.util.concurrent.TimeUnit; + +@Sharable +public class NetherNetXboxSignaling extends SimpleChannelInboundHandler implements NetherNetClientSignaling, NetherNetServerSignaling { + 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<>(); + private NetherNetServerSignaling.NewConnectionHandler newConnectionHandler; + private volatile NetherNetClientSignaling.NotFoundHandler notFoundHandler; + + private volatile List iceServers = new ArrayList<>(); + + /** + * 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/" + networkId); + 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); + } + + @Override + public String getLocalNetworkId() { + return this.localNetworkId; + } + + @Override + public synchronized CompletableFuture> connect(SocketAddress remoteAddress) { + // SocketAddress is ignored for Xbox Signaling Service connection + return connectInternal(); + } + + @Override + public void bind(SocketAddress localAddress) throws ConnectException { + try { + connectInternal().join(); + } catch (Exception e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + close(); + + if (cause instanceof ConnectException) { + throw (ConnectException) cause; + } + + ConnectException ce = new ConnectException("Failed to connect to Xbox Signaling: " + cause.getMessage()); + ce.initCause(cause); + throw ce; + } + } + + private synchronized CompletableFuture> connectInternal() { + if (connectFuture != null) { + return connectFuture; + } + + 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) + .add("Session-Id", UUID.randomUUID().toString()) + .add("Request-Id", UUID.randomUUID().toString()) + ); + + 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) { + 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; + } + + public List getIceServers() { + return this.iceServers; + } + + @Override + public void setNewConnectionHandler(NetherNetServerSignaling.NewConnectionHandler handler) { + this.newConnectionHandler = handler; + } + + @Override + public void setNotFoundHandler(NotFoundHandler handler) { + this.notFoundHandler = 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.debug("NetherNet Signaling WebSocket Connected"); + startPingLoop(ctx); + } else { + super.userEventTriggered(ctx, evt); + } + } + + @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()) { + connectFuture.completeExceptionally(cause); + } + log.error("Signaling Exception: {}", cause.getMessage(), cause); + 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) { + 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); + 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))); + } else { + throw new IllegalStateException("Attempted to send signal to " + targetNetworkId + " but WebSocket is closed or null!"); + } + } + + @Override + public void setSignalHandler(long connectionId, SignalHandler handler) { + this.handlers.put(connectionId, handler); + } + + @Override + public void removeSignalHandler(long connectionId) { + this.handlers.remove(connectionId); + } + + private List parseTurnServers(String jsonString) { + List result = new ArrayList<>(); + try { + JsonObject root = gson.fromJson(jsonString, JsonObject.class); + + JsonArray servers = null; + if (root.has("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")) { + urlsArray = server.getAsJsonArray("Urls"); + } else if (server.has("urls")) { + urlsArray = server.getAsJsonArray("urls"); + } + + if (urlsArray != null) { + urlsArray.forEach(u -> urls.add(u.getAsString())); + + IceServerInfo.Builder info = new IceServerInfo.Builder(); + info.setUrls(urls); + + 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.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.build()); + } + } + } + } catch (Exception e) { + log.error("Failed to parse TURN servers", e); + } + + log.debug("Successfully parsed " + result.size() + " ICE servers."); + 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/util/nethernet/NetherNetScanner.java b/transport-nethernet/src/main/java/dev/kastle/netty/util/nethernet/NetherNetScanner.java new file mode 100644 index 0000000..b334933 --- /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.channel.nethernet.signaling.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(); + 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; diff --git a/transport-raknet/README.md b/transport-raknet/README.md index 4937467..a346874 100644 --- a/transport-raknet/README.md +++ b/transport-raknet/README.md @@ -1 +1,52 @@ # 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  + +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/#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 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 {
User ChannelPipeline
NetherNet Transport
User HandlersApplication Logic
WebRTC Nativelibdatachannel
RTCDataChannelReliability & Ordering
NetherNetClientChannelNetty Adapter
Network (DTLS/UDP)
Encrypted Frame
Reassembled Message
RTCDataChannelBuffer
ByteBuf
Message
NetherNetChildChannelNetty Adapter
User ChannelPipeline(RakClientChannel)
RakNet Internal Pipeline(Parent DatagramChannel)
RakClientProxyRouteHandlerRoutes to/from Client Channel
RakDatagramCodecEncodes/DecodesRakNet Datagrams
RakAcknowledgeHandlerManages ACKs/NACKs
RakSessionCodecReliability, Ordering,Split Packets
Network Socket
Inbound UDP Datagram
EncapsulatedPacket
Outbound UDP Datagram
RakNet Internal Pipeline(RakChildChannel)
User HandlersApplication Logic(e.g. Bedrock Protocol)
RakChildDatagramHandlerBridge to Parent Channel
RakServerChannelUDP Bind & Routing
Routed DatagramPacket
DatagramPacket