Added documents
This commit is contained in:
30 files changed
+1423
-132
No files matched your search
@@ -0,0 +1,31 @@
|
|||||||
|
# Assemble context API
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
rsaEncryptor = RSA1024Encryptor { key, data -> ... }
|
||||||
|
sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... }
|
||||||
|
cipherFactory = { key -> ... }
|
||||||
|
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... }
|
||||||
|
socketEngine = MyCustomTCPSocketImpl()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> If using `DefaultProtocolContext` from `libmc-protocol-context` as the default context implementation,
|
||||||
|
> please refer to [Implementing TCPSocket](Impl-TCP-Socket.md)
|
||||||
|
|
||||||
|
# Modify exists context API implementation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() {
|
||||||
|
val myNewProtocolContext = DefaultProtocolContext.withCustom {
|
||||||
|
rsaEncryptor = RSA1024Encryptor { key, data -> ... }
|
||||||
|
sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... }
|
||||||
|
cipherFactory = { key -> ... }
|
||||||
|
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... }
|
||||||
|
socketEngine = MyCustomTCPSocketImpl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Call `.withCustom` on a `ProtocolContextBuilder` to modify exists context implementation
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Creating a Client
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
public fun main() = runBlocking {
|
||||||
|
val client = createMinecraftClient(
|
||||||
|
"127.0.0.1", 25566, "MyBot",
|
||||||
|
generateOfflineUuid("MyBot"),
|
||||||
|
accessToken = null,
|
||||||
|
context = DefaultProtocolContext.withCustom {
|
||||||
|
socketEngine = KtorNetworkEngine()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client.launch { client.connect() }
|
||||||
|
while (true) {
|
||||||
|
delay(5.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> In the example code above, a `MinecraftClient` is created. This client will connect to an offline server at
|
||||||
|
> `127.0.0.1:25565` using `MyBot` as the player name, and replaces the underlying TCP Socket
|
||||||
|
> implementation with a `ktor-network` based TCP Socket. (For details on how to create a SocketEngine, please refer
|
||||||
|
> to [Implementing TCP Socket](Impl-TCP-Socket.md). For details on how to
|
||||||
|
> create a Context, please refer to [Required APIs](README.md#required-apis))
|
||||||
|
> MinecraftClient implements CoroutineScope, and calling `client.connect()` will execute the connection on a background
|
||||||
|
> thread. Blocking thread to prevent the application from exiting
|
||||||
|
|
||||||
|
## Connecting to an Online-Mode Server
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val accessToken = "eyJraWQiOiIw..."
|
||||||
|
|
||||||
|
public fun main() = runBlocking {
|
||||||
|
val client = createMinecraftClient(
|
||||||
|
// Other parameters
|
||||||
|
username = "RTAkland",
|
||||||
|
uuid = Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"),
|
||||||
|
accessToken = accessToken,
|
||||||
|
// Other parameters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> In the example code above, the client will connect to the server using `RTAkland` as the player name.
|
||||||
|
|
||||||
|
# Get an AccessToken
|
||||||
|
|
||||||
|
Open [minecraft.net](https://minecraft.net), log in, press `F12`, and run the following code in the Console:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
console.log(`; ${document.cookie}`.split('; bearer_token=').pop().split(';').shift())
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: AccessTokens are valid for 24 hours
|
||||||
|
|
||||||
|
# Listening for Packets
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Listen for specific received packets (must start with Clientbound)
|
||||||
|
client.onPacket<ClientboundLoginSuccessPacket> {
|
||||||
|
println(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for all received packets
|
||||||
|
client.on { packet, direction ->
|
||||||
|
println("$direction ->$packet")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for outgoing packets (must start with Serverbound)
|
||||||
|
cli.onSent<ServerboundPongConfigurationPacket> {
|
||||||
|
println(it)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Sending Packets
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Only packets starting with Serverbound can be sent, otherwise an UnsupportedOperationException will be thrown
|
||||||
|
client.networkChannel.sendPacket(
|
||||||
|
ServerboundChatCommandPacket(command = "say Hello from libmc")
|
||||||
|
)
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Before start
|
||||||
|
|
||||||
|
`AES-128-CFB8`, `RSA1024` and `SHA1` has been implemented in `libmc-protocol-context` module, it uses
|
||||||
|
`cryptography-kotlin`(and its platform based provider)
|
||||||
|
|
||||||
|
> All impl example below are based on Java's built-in security & cryptography API
|
||||||
|
|
||||||
|
# AES-128-CFB8
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class JavaAesCipher(sharedKey: ByteArray) : NetworkCipher {
|
||||||
|
private val encryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply {
|
||||||
|
init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val decryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply {
|
||||||
|
init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encrypt(buffer: ByteArray, offset: Int, length: Int) {
|
||||||
|
encryptCipher.update(buffer, offset, length, buffer, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun decrypt(buffer: ByteArray, offset: Int, length: Int) {
|
||||||
|
decryptCipher.update(buffer, offset, length, buffer, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# RSA1024
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun encrypt(publicKeyBytes: ByteArray, data: ByteArray): ByteArray {
|
||||||
|
val keySpec = X509EncodedKeySpec(publicKeyBytes)
|
||||||
|
val keyFactory = KeyFactory.getInstance("RSA")
|
||||||
|
val publicKey = keyFactory.generatePublic(keySpec)
|
||||||
|
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
|
||||||
|
return cipher.doFinal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
rsaEncryptor = RSA1024Encryptor { key: ByteArray, data: ByteArray -> encrypt(key, data) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# SHA1
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun minecraftServerIdHash(serverId: String, secretKey: ByteArray, publicKey: ByteArray): String {
|
||||||
|
val serverIdBytes: ByteArray = serverId.encodeToByteArray()
|
||||||
|
for (b in serverIdBytes) require((b.toInt() and 0xFF) <= 0x7F) { "serverId contains non-US-ASCII character" }
|
||||||
|
val data = ByteArray(serverIdBytes.size + secretKey.size + publicKey.size)
|
||||||
|
System.arraycopy(serverIdBytes, 0, data, 0, serverIdBytes.size)
|
||||||
|
System.arraycopy(secretKey, 0, data, serverIdBytes.size, secretKey.size)
|
||||||
|
System.arraycopy(publicKey, 0, data, serverIdBytes.size + secretKey.size, publicKey.size)
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1")
|
||||||
|
val hash = digest.digest(data)
|
||||||
|
return BigInteger(hash).toString(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
sha1Hasher = Sha1Hasher { serverId: String, secretKey: ByteArray, publicKey: ByteArray ->
|
||||||
|
minecraftServerIdHash(serverId, secretKey, publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Ktor based http client
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val httpClient = HttpClient()
|
||||||
|
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash ->
|
||||||
|
val status = httpClient.post(url) {
|
||||||
|
headers { header("Content-Type", "application/json") }
|
||||||
|
setBody("{\"accessToken\":\"$accessToken\", \"selectedProfile\":\"$uuid\", \"serverId\":\"$serverIdHash\"}")
|
||||||
|
}.status
|
||||||
|
require(status == HttpStatusCode.NoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# HttpURLConnection based http client
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun sendJoinRequest(url: String, accessToken: String, uuid: String, serverIdHash: String): Boolean {
|
||||||
|
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
doOutput = true
|
||||||
|
connectTimeout = 5000
|
||||||
|
readTimeout = 5000
|
||||||
|
}
|
||||||
|
val jsonPayload = """{"accessToken":"$accessToken","selectedProfile":"$uuid","serverId":"$serverIdHash"}"""
|
||||||
|
connection.outputStream.use { os -> os.write(jsonPayload.toByteArray(Charsets.UTF_8)) }
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
connection.disconnect()
|
||||||
|
return responseCode == HttpURLConnection.HTTP_NO_CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash ->
|
||||||
|
val success = sendJoinRequest(url, accessToken, uuid, serverIdHash)
|
||||||
|
require(success) { "Failed to authenticate with Mojang session server" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# Before started
|
||||||
|
|
||||||
|
If the TCP Socket library is non-suspending, **DO NOT** wrap I/O read and write operations with `withContext`.
|
||||||
|
It will cause frequent thread context switching during network I/O, leading to a degradation in application
|
||||||
|
performance
|
||||||
|
|
||||||
|
# Netty based TCP Socket implementation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation("io.netty:netty-transport:4.2.17.Final")
|
||||||
|
implementation("io.netty:netty-buffer:4.2.17.Final")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand code</summary>
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import cn.rtast.libmc.network.RawSocket
|
||||||
|
import cn.rtast.libmc.network.ReadChannel
|
||||||
|
import cn.rtast.libmc.network.SocketEngine
|
||||||
|
import cn.rtast.libmc.network.WriteChannel
|
||||||
|
import io.netty.bootstrap.Bootstrap
|
||||||
|
import io.netty.buffer.ByteBuf
|
||||||
|
import io.netty.buffer.Unpooled
|
||||||
|
import io.netty.channel.*
|
||||||
|
import io.netty.channel.nio.NioIoHandler
|
||||||
|
import io.netty.channel.socket.SocketChannel
|
||||||
|
import io.netty.channel.socket.nio.NioSocketChannel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import io.netty.channel.Channel as NettyChannel
|
||||||
|
import kotlinx.coroutines.channels.Channel as KotlinChannel
|
||||||
|
|
||||||
|
public class NettyNetworkSocketEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = NettyNetworkSocket(host, port)
|
||||||
|
|
||||||
|
private class NettyNetworkSocket(private val host: String, private val port: Int) : RawSocket {
|
||||||
|
private val workerGroup: EventLoopGroup = MultiThreadIoEventLoopGroup(0, NioIoHandler.newFactory())
|
||||||
|
private lateinit var channel: NettyChannel
|
||||||
|
private val inboundQueue = KotlinChannel<ByteArray>(KotlinChannel.UNLIMITED)
|
||||||
|
private lateinit var readChannel: NettyReadChannel
|
||||||
|
private lateinit var writeChannel: NettyWriteChannel
|
||||||
|
|
||||||
|
override suspend fun connect() {
|
||||||
|
val bootstrap = Bootstrap()
|
||||||
|
.group(workerGroup)
|
||||||
|
.channel(NioSocketChannel::class.java)
|
||||||
|
.option(ChannelOption.TCP_NODELAY, true)
|
||||||
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
|
||||||
|
.handler(object : ChannelInitializer<SocketChannel>() {
|
||||||
|
override fun initChannel(ch: SocketChannel) {
|
||||||
|
ch.pipeline().addLast(object : ChannelInboundHandlerAdapter() {
|
||||||
|
override fun channelRead(ctx: ChannelHandlerContext, msg: Any) {
|
||||||
|
if (msg is ByteBuf) {
|
||||||
|
try {
|
||||||
|
val bytes = ByteArray(msg.readableBytes())
|
||||||
|
msg.readBytes(bytes)
|
||||||
|
inboundQueue.trySend(bytes)
|
||||||
|
} finally {
|
||||||
|
msg.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
|
||||||
|
inboundQueue.close(cause)
|
||||||
|
ctx.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun channelInactive(ctx: ChannelHandlerContext) {
|
||||||
|
inboundQueue.close()
|
||||||
|
super.channelInactive(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
channel = withContext(Dispatchers.IO) {
|
||||||
|
bootstrap.connect(host, port).suspendAwait()
|
||||||
|
}
|
||||||
|
|
||||||
|
readChannel = NettyReadChannel(inboundQueue)
|
||||||
|
writeChannel = NettyWriteChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openReadChannel(): ReadChannel = readChannel
|
||||||
|
override fun openWriteChannel(): WriteChannel = writeChannel
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (::channel.isInitialized && channel.isOpen) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
inboundQueue.close()
|
||||||
|
workerGroup.shutdownGracefully()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NettyReadChannel(private val inboundQueue: KotlinChannel<ByteArray>) : ReadChannel {
|
||||||
|
private var currentChunk: ByteArray? = null
|
||||||
|
private var chunkOffset = 0
|
||||||
|
|
||||||
|
private suspend fun fetchNextChunk() {
|
||||||
|
val next = inboundQueue.receiveCatching().getOrNull()
|
||||||
|
?: throw IllegalStateException("Socket/Channel closed while reading")
|
||||||
|
currentChunk = next
|
||||||
|
chunkOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readByte(): Byte {
|
||||||
|
while (currentChunk == null || chunkOffset >= currentChunk!!.size) {
|
||||||
|
fetchNextChunk()
|
||||||
|
}
|
||||||
|
val chunk = currentChunk!!
|
||||||
|
return chunk[chunkOffset++]
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray {
|
||||||
|
val result = ByteArray(length)
|
||||||
|
readFully(result, 0, length)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int) {
|
||||||
|
var written = start
|
||||||
|
|
||||||
|
while (written < end) {
|
||||||
|
while (currentChunk == null || chunkOffset >= currentChunk!!.size) {
|
||||||
|
fetchNextChunk()
|
||||||
|
}
|
||||||
|
val chunk = currentChunk!!
|
||||||
|
val available = chunk.size - chunkOffset
|
||||||
|
val toCopy = minOf(available, end - written)
|
||||||
|
|
||||||
|
chunk.copyInto(
|
||||||
|
out,
|
||||||
|
destinationOffset = written,
|
||||||
|
startIndex = chunkOffset,
|
||||||
|
endIndex = chunkOffset + toCopy
|
||||||
|
)
|
||||||
|
chunkOffset += toCopy
|
||||||
|
written += toCopy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NettyWriteChannel(private val channel: NettyChannel) : WriteChannel {
|
||||||
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
val length = endIndex - startIndex
|
||||||
|
if (length <= 0) return
|
||||||
|
|
||||||
|
val nettyBuf = Unpooled.copiedBuffer(value, startIndex, length)
|
||||||
|
|
||||||
|
writeMutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
channel.writeAndFlush(nettyBuf).suspendAwait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
writeMutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
channel.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun ChannelFuture.suspendAwait(): NettyChannel = suspendCancellableCoroutine { cont ->
|
||||||
|
if (isDone) {
|
||||||
|
if (isSuccess) cont.resume(channel())
|
||||||
|
else cont.resumeWithException(cause() ?: RuntimeException("Netty operation failed"))
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
addListener { future ->
|
||||||
|
if (future.isSuccess) {
|
||||||
|
cont.resume(channel())
|
||||||
|
} else {
|
||||||
|
cont.resumeWithException(future.cause() ?: RuntimeException("Netty operation failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cont.invokeOnCancellation { cancel(false) }
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Also copy the imports
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
# ktor-network based TCP Socket implementation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation("io.ktor:ktor-network:3.5.2")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand</summary>
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
public class KtorNetworkEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = KtorNetworkSocket(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorNetworkSocket(private val host: String, private val port: Int) : RawSocket {
|
||||||
|
private val sm = SelectorManager(Dispatchers.IO)
|
||||||
|
private lateinit var socket: Socket
|
||||||
|
|
||||||
|
override suspend fun connect(): Unit = run { socket = aSocket(sm).tcp().connect(host, port) }
|
||||||
|
override fun openReadChannel(): ReadChannel = KtorReadChannel(socket.openReadChannel())
|
||||||
|
override fun openWriteChannel(): WriteChannel = KtorWriteChannel(socket.openWriteChannel())
|
||||||
|
override fun close() {
|
||||||
|
socket.close()
|
||||||
|
sm.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorReadChannel(private val readChannel: ByteReadChannel) : ReadChannel {
|
||||||
|
override suspend fun readByte(): Byte = readChannel.readByte()
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray = readChannel.readByteArray(length)
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int): Unit =
|
||||||
|
readChannel.readFully(out, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorWriteChannel(private val writeChannel: ByteWriteChannel) : WriteChannel {
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
writeChannel.writeFully(value, startIndex, endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush(): Unit = writeChannel.flush()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
# Java built-in Socket based implementation
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Click to expand code</summary>
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class JavaSocketEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaNetworkSocket(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val connectTimeoutMs: Int = 10000,
|
||||||
|
) : RawSocket {
|
||||||
|
private lateinit var socket: Socket
|
||||||
|
private lateinit var readChannel: JavaReadChannel
|
||||||
|
private lateinit var writeChannel: JavaWriteChannel
|
||||||
|
|
||||||
|
override suspend fun connect() {
|
||||||
|
val s = Socket()
|
||||||
|
s.tcpNoDelay = true
|
||||||
|
s.connect(InetSocketAddress(host, port), connectTimeoutMs)
|
||||||
|
|
||||||
|
socket = s
|
||||||
|
readChannel = JavaReadChannel(s.getInputStream())
|
||||||
|
writeChannel = JavaWriteChannel(s.getOutputStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openReadChannel(): ReadChannel = readChannel
|
||||||
|
override fun openWriteChannel(): WriteChannel = writeChannel
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (::socket.isInitialized && !socket.isClosed) {
|
||||||
|
runCatching { socket.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaReadChannel(private val inputStream: InputStream) : ReadChannel {
|
||||||
|
override suspend fun readByte(): Byte {
|
||||||
|
val b = inputStream.read()
|
||||||
|
if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte")
|
||||||
|
return b.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray {
|
||||||
|
val buffer = ByteArray(length)
|
||||||
|
readFullyInternal(buffer, 0, length)
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int) {
|
||||||
|
readFullyInternal(out, start, end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) {
|
||||||
|
var bytesRead = 0
|
||||||
|
while (bytesRead < length) {
|
||||||
|
val count = inputStream.read(out, offset + bytesRead, length - bytesRead)
|
||||||
|
if (count == -1) {
|
||||||
|
throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)")
|
||||||
|
}
|
||||||
|
bytesRead += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel {
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
outputStream.write(value, startIndex, endIndex - startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
outputStream.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# libmc
|
||||||
|
|
||||||
|
A lightweight, modern Minecraft client protocol library designed for Kotlin Native & JVM. The core protocol library
|
||||||
|
module relies on the following dependencies:
|
||||||
|
|
||||||
|
- Standard Library (`kotlin-stdlib`)
|
||||||
|
- I/O (`kotlinx-io`) - Efficiently wraps each packet into a Buffer
|
||||||
|
- Coroutines (`kotlinx-coroutines`) - Enables high-performance asynchronous operations
|
||||||
|
|
||||||
|
## Required APIs
|
||||||
|
|
||||||
|
| Module Name | Required | Notes |
|
||||||
|
|:-------------|:------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| TCP Socket | Yes | The `protocol` module does not have a built-in TCP Socket implementation. [Implement TCP Socket](Impl-TCP-Socket.md) |
|
||||||
|
| AES-128-CFB8 | Conditional | Required only when logging into an `online-mode` server to encrypt/decrypt traffic. [Implement AES-128-CFB8](Impl-Crypto.md#AES-128-CFB8) |
|
||||||
|
| RSA 1024 | Conditional | Required only when logging into an `online-mode` server to encrypt the shared secret with the server's public key. [Implement RSA 1024](Impl-Crypto.md#RSA1024) |
|
||||||
|
| SHA1 | Conditional | Required only when logging into an `online-mode` server to compute the Server ID hash. [Implement SHA1](Impl-Crypto.md#SHA1) |
|
||||||
|
| HTTP Client | Conditional | Required only when logging into an `online-mode` server to send join request to mojang's session server. [Implement HTTP Client](Impl-HTTP-Client.md) |
|
||||||
|
|
||||||
|
# Get started
|
||||||
|
|
||||||
|
> `libmc-protocol` is current under development. It only supports the latest Minecraft version
|
||||||
|
> (Current supported Minecraft version: `26.2`, Protocol Version: `776`)
|
||||||
|
|
||||||
|
[Start using libmc-protocol](Get-started.md)
|
||||||
|
|
||||||
|
# Assemble all context APIs
|
||||||
|
|
||||||
|
[Assemble context APIs](Assemble-context.md)
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
* **AES-128-CFB8** (`NetworkCipher`): Handles in-place network packet encryption and decryption.
|
|
||||||
* **SHA-1** (`Sha1Hasher`): Computes the Minecraft server ID hash for online-mode authentication.
|
|
||||||
* **RSA-1024** (`RSA1024Encryptor`): Encrypts the shared secret and verify token during the login handshake.
|
|
||||||
* **Auth Join Request** (`AuthenticationProvider`): Sends the HTTP POST request to Mojang's session server
|
|
||||||
(`https://sessionserver.mojang.com/session/minecraft/join`).
|
|
||||||
|
|
||||||
[Use libmc-protocol-encrypt](https://repo.rtast.cn/packages/-/cn.rtast.libmc:protocol-encrypt)
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
fun main() {
|
|
||||||
val client = createMinecraftClient(
|
|
||||||
// other parameter
|
|
||||||
crypto = DefaultProtocolContext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 组合上下文API
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
rsaEncryptor = RSA1024Encryptor { key, data -> ... }
|
||||||
|
sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... }
|
||||||
|
cipherFactory = { key -> ... }
|
||||||
|
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... }
|
||||||
|
socketEngine = MyCustomTCPSocketImpl()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 如果使用`libmc-protocol-context`中的`DefaultProtocolContext`作为默认上下文实现,
|
||||||
|
> 请参阅[实现 TCPSocket](Impl-TCP-Socket-zh.md)
|
||||||
|
|
||||||
|
# 修改默认实现
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() {
|
||||||
|
val myNewProtocolContext = DefaultProtocolContext.withCustom {
|
||||||
|
rsaEncryptor = RSA1024Encryptor { key, data -> ... }
|
||||||
|
sha1Hasher = Sha1Hasher { serverId, secretKey, publicKey -> ... }
|
||||||
|
cipherFactory = { key -> ... }
|
||||||
|
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash -> ... }
|
||||||
|
socketEngine = MyCustomTCPSocketImpl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 使用`.withCustom`来修改已有的上下文API实现
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 创建客户端
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
public fun main() = runBlocking {
|
||||||
|
val client = createMinecraftClient(
|
||||||
|
"127.0.0.1", 25566, "MyBot",
|
||||||
|
generateOfflineUuid("MyBot"),
|
||||||
|
accessToken = null,
|
||||||
|
context = DefaultProtocolContext.withCustom {
|
||||||
|
socketEngine = KtorNetworkEngine()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client.launch { client.connect() }
|
||||||
|
while (true) {
|
||||||
|
delay(5.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 在上面的示例代码中, 创建了一个`MinecraftClient`, 这个客户端将会使用`MyBot`作为玩家名称连接到`127.0.0.1:25565`的`离线`
|
||||||
|
> 服务器, 并且将底层TCP Socket实现替换为了基于`ktor-network`实现的TCP Socket.
|
||||||
|
> (有关如何创建`SocketEngine`请参阅[实现TCP Socket](Impl-TCP-Socket-zh.md),
|
||||||
|
> 有关如何创建Context请参阅[需要实现的API](README-zh.md#需要实现的api))
|
||||||
|
> `MinecraftClient`实现了`CoroutineContext`, 使用`client.connect()`后将会切换到后台线程执行, 添加阻塞线程代码以免程序直接退出.
|
||||||
|
|
||||||
|
## 连接到在线服务器
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val accessToken = "eyJraWQiOiIw..."
|
||||||
|
|
||||||
|
public fun main() = runBlocking {
|
||||||
|
val client = createMinecraftClient(
|
||||||
|
// 其他参数
|
||||||
|
username = "RTAkland",
|
||||||
|
uuid = Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"),
|
||||||
|
accessToken = accessToken,
|
||||||
|
// 其他参数
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 上面的示例代码中, 将会使用`RTAkland`作为玩家名称连接到服务器
|
||||||
|
|
||||||
|
### 快速获取AccessToken
|
||||||
|
|
||||||
|
打开[minecraft.net](https://minecraft.net)并登录后按下F12, 在Console内输入
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
console.log(`; ${document.cookie}`.split('; bearer_token=').pop().split(';').shift())
|
||||||
|
```
|
||||||
|
|
||||||
|
> AccessToken的有效期为24小时
|
||||||
|
|
||||||
|
# 监听数据包
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 监听接收到的指定类型的数据包, 必须以Clientbound开头
|
||||||
|
client.onPacket<ClientboundLoginSuccessPacket> {
|
||||||
|
println(it)
|
||||||
|
}
|
||||||
|
// 监听所有接收到的数据包
|
||||||
|
client.on { packet, direction ->
|
||||||
|
println("$direction -> $packet")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听被发送出去的数据包, 必须以Serverbound开头
|
||||||
|
cli.onSent<ServerboundPongConfigurationPacket> {
|
||||||
|
println(it)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 发送数据包
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 必须以Serverbound开头的数据包才可以被发送, 否则将会抛出UnsupportedOperationException异常
|
||||||
|
client.networkChannel.sendPacket(
|
||||||
|
ServerboundChatCommandPacket(command = "say Hello from libmc")
|
||||||
|
)
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Before start
|
||||||
|
|
||||||
|
`libmc-protocol-context` 模块中默认实现了 `AES-128-CFB8`, `RSA1024` 以及 `SHA1`,
|
||||||
|
三种加密/签名算法都基于`cryptography-kotlin`及其对应平台的provider
|
||||||
|
|
||||||
|
> 下面给出的示例代码均基于Java内置的密码学API
|
||||||
|
|
||||||
|
# AES-128-CFB8
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class JavaAesCipher(sharedKey: ByteArray) : NetworkCipher {
|
||||||
|
private val encryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply {
|
||||||
|
init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val decryptCipher = Cipher.getInstance("AES/CFB8/NoPadding").apply {
|
||||||
|
init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedKey, "AES"), IvParameterSpec(sharedKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun encrypt(buffer: ByteArray, offset: Int, length: Int) {
|
||||||
|
encryptCipher.update(buffer, offset, length, buffer, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun decrypt(buffer: ByteArray, offset: Int, length: Int) {
|
||||||
|
decryptCipher.update(buffer, offset, length, buffer, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# RSA1024
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun encrypt(publicKeyBytes: ByteArray, data: ByteArray): ByteArray {
|
||||||
|
val keySpec = X509EncodedKeySpec(publicKeyBytes)
|
||||||
|
val keyFactory = KeyFactory.getInstance("RSA")
|
||||||
|
val publicKey = keyFactory.generatePublic(keySpec)
|
||||||
|
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
|
||||||
|
return cipher.doFinal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
rsaEncryptor = RSA1024Encryptor { key: ByteArray, data: ByteArray -> encrypt(key, data) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# SHA1
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun minecraftServerIdHash(serverId: String, secretKey: ByteArray, publicKey: ByteArray): String {
|
||||||
|
val serverIdBytes: ByteArray = serverId.encodeToByteArray()
|
||||||
|
for (b in serverIdBytes) require((b.toInt() and 0xFF) <= 0x7F) { "serverId contains non-US-ASCII character" }
|
||||||
|
val data = ByteArray(serverIdBytes.size + secretKey.size + publicKey.size)
|
||||||
|
System.arraycopy(serverIdBytes, 0, data, 0, serverIdBytes.size)
|
||||||
|
System.arraycopy(secretKey, 0, data, serverIdBytes.size, secretKey.size)
|
||||||
|
System.arraycopy(publicKey, 0, data, serverIdBytes.size + secretKey.size, publicKey.size)
|
||||||
|
val digest = MessageDigest.getInstance("SHA-1")
|
||||||
|
val hash = digest.digest(data)
|
||||||
|
return BigInteger(hash).toString(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
sha1Hasher = Sha1Hasher { serverId: String, secretKey: ByteArray, publicKey: ByteArray ->
|
||||||
|
minecraftServerIdHash(serverId, secretKey, publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 前言
|
||||||
|
|
||||||
|
`libmc-protocol-context` 中提供了默认HTTP客户端实现, 如果使用了此模块则不需要手动配置HTTP客户端认证部分
|
||||||
|
|
||||||
|
# 基于Ktor的HTTP客户端
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val httpClient = HttpClient()
|
||||||
|
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash ->
|
||||||
|
val status = httpClient.post(url) {
|
||||||
|
headers { header("Content-Type", "application/json") }
|
||||||
|
setBody("{\"accessToken\":\"$accessToken\", \"selectedProfile\":\"$uuid\", \"serverId\":\"$serverIdHash\"}")
|
||||||
|
}.status
|
||||||
|
require(status == HttpStatusCode.NoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 基于Java8 HttpURLConnection的HTTP客户端
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private fun sendJoinRequest(url: String, accessToken: String, uuid: String, serverIdHash: String): Boolean {
|
||||||
|
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
doOutput = true
|
||||||
|
connectTimeout = 5000
|
||||||
|
readTimeout = 5000
|
||||||
|
}
|
||||||
|
val jsonPayload = """{"accessToken":"$accessToken","selectedProfile":"$uuid","serverId":"$serverIdHash"}"""
|
||||||
|
connection.outputStream.use { os -> os.write(jsonPayload.toByteArray(Charsets.UTF_8)) }
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
connection.disconnect()
|
||||||
|
return responseCode == HttpURLConnection.HTTP_NO_CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
public val CustomProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
|
authProvider = AuthenticationProvider { url, accessToken, uuid, serverIdHash ->
|
||||||
|
val success = sendJoinRequest(url, accessToken, uuid, serverIdHash)
|
||||||
|
require(success) { "Failed to authenticate with Mojang session server" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
# 前言
|
||||||
|
|
||||||
|
如果底层TCP Socket库是非挂起的, 那么**不要**将IO读写操作使用`withContext`包裹,
|
||||||
|
这会导致在网络IO中频繁的切换线程导致程序性能**下降**.
|
||||||
|
|
||||||
|
# 基于Netty的TCP Socket实现
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation("io.netty:netty-transport:4.2.17.Final")
|
||||||
|
implementation("io.netty:netty-buffer:4.2.17.Final")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>点击展开代码</summary>
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
import cn.rtast.libmc.network.RawSocket
|
||||||
|
import cn.rtast.libmc.network.ReadChannel
|
||||||
|
import cn.rtast.libmc.network.SocketEngine
|
||||||
|
import cn.rtast.libmc.network.WriteChannel
|
||||||
|
import io.netty.bootstrap.Bootstrap
|
||||||
|
import io.netty.buffer.ByteBuf
|
||||||
|
import io.netty.buffer.Unpooled
|
||||||
|
import io.netty.channel.*
|
||||||
|
import io.netty.channel.nio.NioIoHandler
|
||||||
|
import io.netty.channel.socket.SocketChannel
|
||||||
|
import io.netty.channel.socket.nio.NioSocketChannel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import io.netty.channel.Channel as NettyChannel
|
||||||
|
import kotlinx.coroutines.channels.Channel as KotlinChannel
|
||||||
|
|
||||||
|
public class NettyNetworkSocketEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = NettyNetworkSocket(host, port)
|
||||||
|
|
||||||
|
private class NettyNetworkSocket(private val host: String, private val port: Int) : RawSocket {
|
||||||
|
private val workerGroup: EventLoopGroup = MultiThreadIoEventLoopGroup(0, NioIoHandler.newFactory())
|
||||||
|
private lateinit var channel: NettyChannel
|
||||||
|
private val inboundQueue = KotlinChannel<ByteArray>(KotlinChannel.UNLIMITED)
|
||||||
|
private lateinit var readChannel: NettyReadChannel
|
||||||
|
private lateinit var writeChannel: NettyWriteChannel
|
||||||
|
|
||||||
|
override suspend fun connect() {
|
||||||
|
val bootstrap = Bootstrap()
|
||||||
|
.group(workerGroup)
|
||||||
|
.channel(NioSocketChannel::class.java)
|
||||||
|
.option(ChannelOption.TCP_NODELAY, true)
|
||||||
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
|
||||||
|
.handler(object : ChannelInitializer<SocketChannel>() {
|
||||||
|
override fun initChannel(ch: SocketChannel) {
|
||||||
|
ch.pipeline().addLast(object : ChannelInboundHandlerAdapter() {
|
||||||
|
override fun channelRead(ctx: ChannelHandlerContext, msg: Any) {
|
||||||
|
if (msg is ByteBuf) {
|
||||||
|
try {
|
||||||
|
val bytes = ByteArray(msg.readableBytes())
|
||||||
|
msg.readBytes(bytes)
|
||||||
|
inboundQueue.trySend(bytes)
|
||||||
|
} finally {
|
||||||
|
msg.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
|
||||||
|
inboundQueue.close(cause)
|
||||||
|
ctx.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun channelInactive(ctx: ChannelHandlerContext) {
|
||||||
|
inboundQueue.close()
|
||||||
|
super.channelInactive(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
channel = withContext(Dispatchers.IO) {
|
||||||
|
bootstrap.connect(host, port).suspendAwait()
|
||||||
|
}
|
||||||
|
|
||||||
|
readChannel = NettyReadChannel(inboundQueue)
|
||||||
|
writeChannel = NettyWriteChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openReadChannel(): ReadChannel = readChannel
|
||||||
|
override fun openWriteChannel(): WriteChannel = writeChannel
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (::channel.isInitialized && channel.isOpen) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
inboundQueue.close()
|
||||||
|
workerGroup.shutdownGracefully()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NettyReadChannel(private val inboundQueue: KotlinChannel<ByteArray>) : ReadChannel {
|
||||||
|
private var currentChunk: ByteArray? = null
|
||||||
|
private var chunkOffset = 0
|
||||||
|
|
||||||
|
private suspend fun fetchNextChunk() {
|
||||||
|
val next = inboundQueue.receiveCatching().getOrNull()
|
||||||
|
?: throw IllegalStateException("Socket/Channel closed while reading")
|
||||||
|
currentChunk = next
|
||||||
|
chunkOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readByte(): Byte {
|
||||||
|
while (currentChunk == null || chunkOffset >= currentChunk!!.size) {
|
||||||
|
fetchNextChunk()
|
||||||
|
}
|
||||||
|
val chunk = currentChunk!!
|
||||||
|
return chunk[chunkOffset++]
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray {
|
||||||
|
val result = ByteArray(length)
|
||||||
|
readFully(result, 0, length)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int) {
|
||||||
|
var written = start
|
||||||
|
|
||||||
|
while (written < end) {
|
||||||
|
while (currentChunk == null || chunkOffset >= currentChunk!!.size) {
|
||||||
|
fetchNextChunk()
|
||||||
|
}
|
||||||
|
val chunk = currentChunk!!
|
||||||
|
val available = chunk.size - chunkOffset
|
||||||
|
val toCopy = minOf(available, end - written)
|
||||||
|
|
||||||
|
chunk.copyInto(
|
||||||
|
out,
|
||||||
|
destinationOffset = written,
|
||||||
|
startIndex = chunkOffset,
|
||||||
|
endIndex = chunkOffset + toCopy
|
||||||
|
)
|
||||||
|
chunkOffset += toCopy
|
||||||
|
written += toCopy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NettyWriteChannel(private val channel: NettyChannel) : WriteChannel {
|
||||||
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
val length = endIndex - startIndex
|
||||||
|
if (length <= 0) return
|
||||||
|
|
||||||
|
val nettyBuf = Unpooled.copiedBuffer(value, startIndex, length)
|
||||||
|
|
||||||
|
writeMutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
channel.writeAndFlush(nettyBuf).suspendAwait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
writeMutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
channel.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun ChannelFuture.suspendAwait(): NettyChannel = suspendCancellableCoroutine { cont ->
|
||||||
|
if (isDone) {
|
||||||
|
if (isSuccess) cont.resume(channel())
|
||||||
|
else cont.resumeWithException(cause() ?: RuntimeException("Netty operation failed"))
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
addListener { future ->
|
||||||
|
if (future.isSuccess) {
|
||||||
|
cont.resume(channel())
|
||||||
|
} else {
|
||||||
|
cont.resumeWithException(future.cause() ?: RuntimeException("Netty operation failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cont.invokeOnCancellation { cancel(false) }
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 请一并复制import部分的代码
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
# 基于ktor-network的TCP Socket实现
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation("io.ktor:ktor-network:3.5.2")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>点击展开代码</summary>
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
public class KtorNetworkEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = KtorNetworkSocket(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorNetworkSocket(private val host: String, private val port: Int) : RawSocket {
|
||||||
|
private val sm = SelectorManager(Dispatchers.IO)
|
||||||
|
private lateinit var socket: Socket
|
||||||
|
|
||||||
|
override suspend fun connect(): Unit = run { socket = aSocket(sm).tcp().connect(host, port) }
|
||||||
|
override fun openReadChannel(): ReadChannel = KtorReadChannel(socket.openReadChannel())
|
||||||
|
override fun openWriteChannel(): WriteChannel = KtorWriteChannel(socket.openWriteChannel())
|
||||||
|
override fun close() {
|
||||||
|
socket.close()
|
||||||
|
sm.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorReadChannel(private val readChannel: ByteReadChannel) : ReadChannel {
|
||||||
|
override suspend fun readByte(): Byte = readChannel.readByte()
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray = readChannel.readByteArray(length)
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int): Unit =
|
||||||
|
readChannel.readFully(out, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorWriteChannel(private val writeChannel: ByteWriteChannel) : WriteChannel {
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
writeChannel.writeFully(value, startIndex, endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush(): Unit = writeChannel.flush()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
# 基于Java内置Socket的TCP Socket实现
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>点击展开代码</summary>
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class JavaSocketEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaNetworkSocket(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val connectTimeoutMs: Int = 10000,
|
||||||
|
) : RawSocket {
|
||||||
|
private lateinit var socket: Socket
|
||||||
|
private lateinit var readChannel: JavaReadChannel
|
||||||
|
private lateinit var writeChannel: JavaWriteChannel
|
||||||
|
|
||||||
|
override suspend fun connect() {
|
||||||
|
val s = Socket()
|
||||||
|
s.tcpNoDelay = true
|
||||||
|
s.connect(InetSocketAddress(host, port), connectTimeoutMs)
|
||||||
|
|
||||||
|
socket = s
|
||||||
|
readChannel = JavaReadChannel(s.getInputStream())
|
||||||
|
writeChannel = JavaWriteChannel(s.getOutputStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openReadChannel(): ReadChannel = readChannel
|
||||||
|
override fun openWriteChannel(): WriteChannel = writeChannel
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (::socket.isInitialized && !socket.isClosed) {
|
||||||
|
runCatching { socket.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaReadChannel(private val inputStream: InputStream) : ReadChannel {
|
||||||
|
override suspend fun readByte(): Byte {
|
||||||
|
val b = inputStream.read()
|
||||||
|
if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte")
|
||||||
|
return b.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray {
|
||||||
|
val buffer = ByteArray(length)
|
||||||
|
readFullyInternal(buffer, 0, length)
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int) {
|
||||||
|
readFullyInternal(out, start, end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) {
|
||||||
|
var bytesRead = 0
|
||||||
|
while (bytesRead < length) {
|
||||||
|
val count = inputStream.read(out, offset + bytesRead, length - bytesRead)
|
||||||
|
if (count == -1) {
|
||||||
|
throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)")
|
||||||
|
}
|
||||||
|
bytesRead += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel {
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
outputStream.write(value, startIndex, endIndex - startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
outputStream.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</details>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# libmc
|
||||||
|
|
||||||
|
一个轻量级适用于Kotlin Native & Jvm的现代Minecraft客户端协议库, 核心协议库模块使用了以下依赖
|
||||||
|
|
||||||
|
- 标准库 (`kotlin-stdlib`)
|
||||||
|
- IO (`kotlinx-io`) - 高效的将每个数据包封装为一个Buffer
|
||||||
|
- 协程库 (`kotlinx-coroutines`) - 实现高性能异步操作
|
||||||
|
|
||||||
|
## 需要实现的API
|
||||||
|
|
||||||
|
| 名称 | 是否必须实现 | 描述 |
|
||||||
|
|:-------------|:-------------|:-----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| TCP Socket | 是 | `protocol` 模块没有内置TCP Socket实现. [实现 TCP Socket](Impl-TCP-Socket-zh.md) |
|
||||||
|
| AES-128-CFB8 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于加密/解密流量. [实现AES-128-CFB8](Impl-Crypto-zh.md#AES-128-CFB8) |
|
||||||
|
| RSA 1024 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于对服务器下发的公钥进行签名. [实现RSA 1024](Impl-Crypto-zh.md#RSA1024) |
|
||||||
|
| SHA1 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于计算服务器的ServerId. [实现SHA1](Impl-Crypto-zh.md#SHA1) |
|
||||||
|
| HTTP 客户端 | 视情况而定 | 仅在登录开启了正版验证(`online-mode`)的服务器时需要, 用于向Mojang的Session服务器发送加入服务器的请求 [实现HTTP 客户端](Impl-HTTP-Client-zh.md) |
|
||||||
|
|
||||||
|
# 开始使用
|
||||||
|
|
||||||
|
> `libmc-protocol`目前正在开发中, 也许存在着某些未被发现的问题.
|
||||||
|
> 它仅支持最新的Minecraft版本 (目前支持的游戏版本: `26.2`, 协议版本: `776`)
|
||||||
|
|
||||||
|
[开始使用libmc-protocol](Get-started-zh.md)
|
||||||
|
|
||||||
|
# 将实现的API组合起来
|
||||||
|
|
||||||
|
[将所有上下文API组合](Assemble-context-zh.md)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/9/7
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package engines
|
||||||
|
|
||||||
|
import cn.rtast.libmc.network.RawSocket
|
||||||
|
import cn.rtast.libmc.network.ReadChannel
|
||||||
|
import cn.rtast.libmc.network.SocketEngine
|
||||||
|
import cn.rtast.libmc.network.WriteChannel
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
class JavaSocketEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaNetworkSocket(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val connectTimeoutMs: Int = 10000,
|
||||||
|
) : RawSocket {
|
||||||
|
private lateinit var socket: Socket
|
||||||
|
private lateinit var readChannel: JavaReadChannel
|
||||||
|
private lateinit var writeChannel: JavaWriteChannel
|
||||||
|
|
||||||
|
override suspend fun connect() {
|
||||||
|
val s = Socket()
|
||||||
|
s.tcpNoDelay = true
|
||||||
|
s.connect(InetSocketAddress(host, port), connectTimeoutMs)
|
||||||
|
|
||||||
|
socket = s
|
||||||
|
readChannel = JavaReadChannel(s.getInputStream())
|
||||||
|
writeChannel = JavaWriteChannel(s.getOutputStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openReadChannel(): ReadChannel = readChannel
|
||||||
|
override fun openWriteChannel(): WriteChannel = writeChannel
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (::socket.isInitialized && !socket.isClosed) {
|
||||||
|
runCatching { socket.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaReadChannel(private val inputStream: InputStream) : ReadChannel {
|
||||||
|
override suspend fun readByte(): Byte {
|
||||||
|
val b = inputStream.read()
|
||||||
|
if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte")
|
||||||
|
return b.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray {
|
||||||
|
val buffer = ByteArray(length)
|
||||||
|
readFullyInternal(buffer, 0, length)
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int) {
|
||||||
|
readFullyInternal(out, start, end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) {
|
||||||
|
var bytesRead = 0
|
||||||
|
while (bytesRead < length) {
|
||||||
|
val count = inputStream.read(out, offset + bytesRead, length - bytesRead)
|
||||||
|
if (count == -1) {
|
||||||
|
throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)")
|
||||||
|
}
|
||||||
|
bytesRead += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel {
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
outputStream.write(value, startIndex, endIndex - startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
outputStream.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/9/8
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package engines
|
||||||
|
|
||||||
|
|
||||||
|
public class KtorNetworkEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = KtorNetworkSocket(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorNetworkSocket(private val host: String, private val port: Int) : RawSocket {
|
||||||
|
private val sm = SelectorManager(Dispatchers.IO)
|
||||||
|
private lateinit var socket: Socket
|
||||||
|
|
||||||
|
override suspend fun connect(): Unit = run { socket = aSocket(sm).tcp().connect(host, port) }
|
||||||
|
override fun openReadChannel(): ReadChannel = KtorReadChannel(socket.openReadChannel())
|
||||||
|
override fun openWriteChannel(): WriteChannel = KtorWriteChannel(socket.openWriteChannel())
|
||||||
|
override fun close() {
|
||||||
|
socket.close()
|
||||||
|
sm.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorReadChannel(private val readChannel: ByteReadChannel) : ReadChannel {
|
||||||
|
override suspend fun readByte(): Byte = readChannel.readByte()
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray = readChannel.readByteArray(length)
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int): Unit =
|
||||||
|
readChannel.readFully(out, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class KtorWriteChannel(private val writeChannel: ByteWriteChannel) : WriteChannel {
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
writeChannel.writeFully(value, startIndex, endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush(): Unit = writeChannel.flush()
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -1,19 +0,0 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
explicitApi()
|
|
||||||
withSourcesJar()
|
|
||||||
|
|
||||||
linuxX64()
|
|
||||||
linuxArm64()
|
|
||||||
macosArm64()
|
|
||||||
mingwX64()
|
|
||||||
jvm { compilerOptions.jvmTarget = JvmTarget.JVM_1_8 }
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
jvmMain.dependencies {
|
|
||||||
api(project(":protocol"))
|
|
||||||
api(libs.ktor.network)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright © 2026 RTAkland
|
|
||||||
* Author: RTAkland
|
|
||||||
* Date: 2026/9/8
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
package engines
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
explicitApi()
|
|
||||||
withSourcesJar()
|
|
||||||
|
|
||||||
jvm { compilerOptions.jvmTarget = JvmTarget.JVM_1_8 }
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
jvmMain.dependencies {
|
|
||||||
api(project(":protocol"))
|
|
||||||
api(libs.netty.handler)
|
|
||||||
api(libs.netty.buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
kotlin = "2.4.10"
|
kotlin = "2.4.10"
|
||||||
kotlinx-io = "0.9.1"
|
kotlinx-io = "0.9.1"
|
||||||
ktor-network = "3.5.2"
|
|
||||||
coroutines-test = "1.11.0"
|
coroutines-test = "1.11.0"
|
||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
ktor-core = "3.5.2"
|
ktor-core = "3.5.2"
|
||||||
@@ -10,7 +9,7 @@ netty = "4.2.17.Final"
|
|||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
|
kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
|
||||||
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor-network" }
|
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor-core" }
|
||||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" }
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines-test" }
|
||||||
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-core" }
|
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-core" }
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import cn.rtast.libmc.network.SocketContext
|
|||||||
import cn.rtast.libmc.network.SocketEngine
|
import cn.rtast.libmc.network.SocketEngine
|
||||||
|
|
||||||
public data class ProtocolContext(
|
public data class ProtocolContext(
|
||||||
val rsaEncryptor: RSA1024Encryptor,
|
val rsaEncryptor: RSA1024Encryptor?,
|
||||||
val sha1Hasher: Sha1Hasher,
|
val sha1Hasher: Sha1Hasher?,
|
||||||
val cipherFactory: (sharedKey: ByteArray) -> NetworkCipher,
|
val cipherFactory: ((sharedKey: ByteArray) -> NetworkCipher)?,
|
||||||
val authProvider: AuthenticationProvider?,
|
val authProvider: AuthenticationProvider?,
|
||||||
override val engine: SocketEngine,
|
override val engine: SocketEngine,
|
||||||
) : SocketContext()
|
) : SocketContext()
|
||||||
@@ -27,12 +27,22 @@ public class ProtocolContextBuilder(private val onlineMode: Boolean) {
|
|||||||
|
|
||||||
public fun build(): ProtocolContext =
|
public fun build(): ProtocolContext =
|
||||||
ProtocolContext(
|
ProtocolContext(
|
||||||
rsaEncryptor = if (::rsaEncryptor.isInitialized) rsaEncryptor else error("rsaEncryptor is required"),
|
rsaEncryptor = if (onlineMode) {
|
||||||
sha1Hasher = if (::sha1Hasher.isInitialized) sha1Hasher else error("sha1Hasher is required"),
|
if (::rsaEncryptor.isInitialized) rsaEncryptor else error("rsaEncryptor is required in online mode")
|
||||||
cipherFactory = if (::cipherFactory.isInitialized) cipherFactory else error("cipherFactory is required"),
|
} else if (::rsaEncryptor.isInitialized) rsaEncryptor else null,
|
||||||
|
|
||||||
|
sha1Hasher = if (onlineMode) {
|
||||||
|
if (::sha1Hasher.isInitialized) sha1Hasher else error("sha1Hasher is required in online mode")
|
||||||
|
} else if (::sha1Hasher.isInitialized) sha1Hasher else null,
|
||||||
|
|
||||||
|
cipherFactory = if (onlineMode) {
|
||||||
|
if (::cipherFactory.isInitialized) cipherFactory else error("cipherFactory is required in online mode")
|
||||||
|
} else if (::cipherFactory.isInitialized) cipherFactory else null,
|
||||||
|
|
||||||
authProvider = if (onlineMode) {
|
authProvider = if (onlineMode) {
|
||||||
if (::authProvider.isInitialized) authProvider else error("authProvider is required in online mode")
|
if (::authProvider.isInitialized) authProvider else error("authProvider is required in online mode")
|
||||||
} else if (::authProvider.isInitialized) authProvider else null,
|
} else if (::authProvider.isInitialized) authProvider else null,
|
||||||
|
|
||||||
engine = if (::socketEngine.isInitialized) socketEngine else error("SocketEngine is not configured")
|
engine = if (::socketEngine.isInitialized) socketEngine else error("SocketEngine is not configured")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ import io.ktor.client.*
|
|||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
|
||||||
public val httpClient: HttpClient = HttpClient()
|
private val httpClient: HttpClient = HttpClient()
|
||||||
|
|
||||||
public val DefaultProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
public val DefaultProtocolContext: ProtocolContextBuilder.() -> Unit = {
|
||||||
rsaEncryptor = RSA1024Encryptor { key, data -> rsaEncrypt(key, data) }
|
rsaEncryptor = RSA1024Encryptor { key, data -> rsaEncrypt(key, data) }
|
||||||
|
|||||||
+4
-8
@@ -29,27 +29,23 @@ public class MinecraftClient internal constructor(
|
|||||||
internal val accessToken: String?,
|
internal val accessToken: String?,
|
||||||
parentJob: Job?,
|
parentJob: Job?,
|
||||||
private val ioDispatcher: CoroutineDispatcher,
|
private val ioDispatcher: CoroutineDispatcher,
|
||||||
protocolContext: ProtocolContext,
|
internal val protocolContext: ProtocolContext,
|
||||||
) : PacketEventDispatcher(), CoroutineScope {
|
) : PacketEventDispatcher(), CoroutineScope {
|
||||||
internal val rsa1024Encryptor = protocolContext.rsaEncryptor
|
|
||||||
internal val serverIdHasher = protocolContext.sha1Hasher
|
|
||||||
internal val authProvider = protocolContext.authProvider
|
|
||||||
internal val stateMachine = ClientStateMachine()
|
internal val stateMachine = ClientStateMachine()
|
||||||
|
|
||||||
public val networkChannel: NetworkChannel = NetworkChannel(
|
public val networkChannel: NetworkChannel = NetworkChannel(
|
||||||
host, port, stateMachine,
|
host, port, stateMachine,
|
||||||
protocolContext.cipherFactory,
|
|
||||||
this, protocolContext
|
this, protocolContext
|
||||||
)
|
)
|
||||||
|
|
||||||
private val internalPacketDispatcher = InternalPacketDispatcher(this, authProvider)
|
private val internalPacketDispatcher = InternalPacketDispatcher(this)
|
||||||
private val clientJob = SupervisorJob(parentJob)
|
private val clientJob = SupervisorJob(parentJob)
|
||||||
private var listenJob: Job? = null
|
private var listenJob: Job? = null
|
||||||
|
|
||||||
public val isOnlineMode: Boolean get() = accessToken != null
|
public val isOnlineMode: Boolean = accessToken != null
|
||||||
public val transactionManager: TransactionIdManager = TransactionIdManager()
|
public val transactionManager: TransactionIdManager = TransactionIdManager()
|
||||||
|
|
||||||
public suspend fun connect(protocolVersion: Int = 776) {
|
public suspend fun connect(protocolVersion: Int = CURRENT_MINECRAFT_PROTOCOL_VERSION) {
|
||||||
networkChannel.connect()
|
networkChannel.connect()
|
||||||
startListening()
|
startListening()
|
||||||
networkChannel.sendPacket(
|
networkChannel.sendPacket(
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/9/8
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package cn.rtast.libmc.protocol.client
|
||||||
|
|
||||||
|
internal const val CURRENT_MINECRAFT_PROTOCOL_VERSION: Int = 776
|
||||||
+7
-9
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
package cn.rtast.libmc.protocol.event
|
package cn.rtast.libmc.protocol.event
|
||||||
|
|
||||||
import cn.rtast.libmc.crypto.AuthenticationProvider
|
|
||||||
import cn.rtast.libmc.packet.MinecraftPacket
|
import cn.rtast.libmc.packet.MinecraftPacket
|
||||||
import cn.rtast.libmc.protocol.client.MinecraftClient
|
import cn.rtast.libmc.protocol.client.MinecraftClient
|
||||||
import cn.rtast.libmc.protocol.packet.configuration.clientbound.*
|
import cn.rtast.libmc.protocol.packet.configuration.clientbound.*
|
||||||
@@ -33,10 +32,7 @@ import cn.rtast.libmc.protocol.util.generateRandom16Bytes
|
|||||||
* Auto respond packets the server needed.
|
* Auto respond packets the server needed.
|
||||||
* Only including `Handshake`, `Login` and `Configuration` State
|
* Only including `Handshake`, `Login` and `Configuration` State
|
||||||
*/
|
*/
|
||||||
public class InternalPacketDispatcher(
|
public class InternalPacketDispatcher(private val client: MinecraftClient) {
|
||||||
private val client: MinecraftClient,
|
|
||||||
private val authProvider: AuthenticationProvider?,
|
|
||||||
) {
|
|
||||||
public suspend fun handleIncomingPackets(packet: MinecraftPacket) {
|
public suspend fun handleIncomingPackets(packet: MinecraftPacket) {
|
||||||
when (packet) {
|
when (packet) {
|
||||||
// login
|
// login
|
||||||
@@ -50,16 +46,18 @@ public class InternalPacketDispatcher(
|
|||||||
is ClientboundHelloPacket -> {
|
is ClientboundHelloPacket -> {
|
||||||
val sharedSecret = generateRandom16Bytes()
|
val sharedSecret = generateRandom16Bytes()
|
||||||
if (client.isOnlineMode) {
|
if (client.isOnlineMode) {
|
||||||
val serverHash = client.serverIdHasher.hash(packet.serverId, sharedSecret, packet.publicKey)
|
val serverHash = client.protocolContext.sha1Hasher!!
|
||||||
authProvider!!.joinServer(
|
.hash(packet.serverId, sharedSecret, packet.publicKey)
|
||||||
|
client.protocolContext.authProvider!!.joinServer(
|
||||||
"https://sessionserver.mojang.com/session/minecraft/join",
|
"https://sessionserver.mojang.com/session/minecraft/join",
|
||||||
client.accessToken!!,
|
client.accessToken!!,
|
||||||
client.uuid.toString().replace("-", ""),
|
client.uuid.toString().replace("-", ""),
|
||||||
serverHash
|
serverHash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val encryptedSecret = client.rsa1024Encryptor.encrypt(packet.publicKey, sharedSecret)
|
val encryptedSecret = client.protocolContext.rsaEncryptor!!.encrypt(packet.publicKey, sharedSecret)
|
||||||
val encryptedVerifyToken = client.rsa1024Encryptor.encrypt(packet.publicKey, packet.verifyToken)
|
val encryptedVerifyToken =
|
||||||
|
client.protocolContext.rsaEncryptor!!.encrypt(packet.publicKey, packet.verifyToken)
|
||||||
client.networkChannel.sendPacket(ServerboundKeyPacket(encryptedSecret, encryptedVerifyToken))
|
client.networkChannel.sendPacket(ServerboundKeyPacket(encryptedSecret, encryptedVerifyToken))
|
||||||
client.networkChannel.session.enableEncryption(sharedSecret)
|
client.networkChannel.session.enableEncryption(sharedSecret)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -26,11 +26,10 @@ public class NetworkChannel internal constructor(
|
|||||||
host: String,
|
host: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
private val stateMachine: ClientStateMachine,
|
private val stateMachine: ClientStateMachine,
|
||||||
cipherProvider: (ByteArray) -> NetworkCipher,
|
|
||||||
private val dispatcher: PacketEventDispatcher,
|
private val dispatcher: PacketEventDispatcher,
|
||||||
protocolContext: ProtocolContext,
|
protocolContext: ProtocolContext,
|
||||||
) {
|
) {
|
||||||
internal val session: NetworkSession = NetworkSession(host, port, cipherProvider, protocolContext)
|
internal val session: NetworkSession = NetworkSession(host, port, protocolContext)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var threshold = -1
|
private var threshold = -1
|
||||||
|
|||||||
+1
-3
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
package cn.rtast.libmc.protocol.network
|
package cn.rtast.libmc.protocol.network
|
||||||
|
|
||||||
import cn.rtast.libmc.crypto.NetworkCipher
|
|
||||||
import cn.rtast.libmc.crypto.ProtocolContext
|
import cn.rtast.libmc.crypto.ProtocolContext
|
||||||
import cn.rtast.libmc.network.RawSocket
|
import cn.rtast.libmc.network.RawSocket
|
||||||
import cn.rtast.libmc.network.ReadChannel
|
import cn.rtast.libmc.network.ReadChannel
|
||||||
@@ -16,7 +15,6 @@ import cn.rtast.libmc.primitives.readVarInt
|
|||||||
public class NetworkSession internal constructor(
|
public class NetworkSession internal constructor(
|
||||||
private val host: String,
|
private val host: String,
|
||||||
private val port: Int,
|
private val port: Int,
|
||||||
private var cipherProvider: (ByteArray) -> NetworkCipher,
|
|
||||||
private val context: ProtocolContext,
|
private val context: ProtocolContext,
|
||||||
) {
|
) {
|
||||||
private var socket: RawSocket? = null
|
private var socket: RawSocket? = null
|
||||||
@@ -38,7 +36,7 @@ public class NetworkSession internal constructor(
|
|||||||
public fun enableEncryption(sharedKey: ByteArray) {
|
public fun enableEncryption(sharedKey: ByteArray) {
|
||||||
val currentRead = requireNotNull(readChannel)
|
val currentRead = requireNotNull(readChannel)
|
||||||
val currentWrite = requireNotNull(writeChannel)
|
val currentWrite = requireNotNull(writeChannel)
|
||||||
val cipher = cipherProvider(sharedKey)
|
val cipher = context.cipherFactory!!.invoke(sharedKey)
|
||||||
this.readChannel = CipherReadChannel(currentRead, cipher)
|
this.readChannel = CipherReadChannel(currentRead, cipher)
|
||||||
this.writeChannel = CipherWriteChannel(currentWrite, cipher)
|
this.writeChannel = CipherWriteChannel(currentWrite, cipher)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -10,6 +10,10 @@ package cn.rtast.libmc.protocol.util
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side managed transaction id manager,
|
||||||
|
* managed an auto-increment transaction id
|
||||||
|
*/
|
||||||
public class TransactionIdManager internal constructor() {
|
public class TransactionIdManager internal constructor() {
|
||||||
private var queryTransactionCounter: Int = 1
|
private var queryTransactionCounter: Int = 1
|
||||||
private var commandSuggestionTransactionCounter: Int = 1
|
private var commandSuggestionTransactionCounter: Int = 1
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright © 2026 RTAkland
|
||||||
|
* Author: RTAkland
|
||||||
|
* Date: 2026/9/7
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
package test
|
||||||
|
|
||||||
|
import cn.rtast.libmc.network.RawSocket
|
||||||
|
import cn.rtast.libmc.network.ReadChannel
|
||||||
|
import cn.rtast.libmc.network.SocketEngine
|
||||||
|
import cn.rtast.libmc.network.WriteChannel
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
class JavaSocketEngine : SocketEngine {
|
||||||
|
override fun create(host: String, port: Int): RawSocket = JavaNetworkSocket(host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaNetworkSocket(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val connectTimeoutMs: Int = 10000,
|
||||||
|
) : RawSocket {
|
||||||
|
private lateinit var socket: Socket
|
||||||
|
private lateinit var readChannel: JavaReadChannel
|
||||||
|
private lateinit var writeChannel: JavaWriteChannel
|
||||||
|
|
||||||
|
override suspend fun connect() {
|
||||||
|
val s = Socket()
|
||||||
|
s.tcpNoDelay = true
|
||||||
|
s.connect(InetSocketAddress(host, port), connectTimeoutMs)
|
||||||
|
|
||||||
|
socket = s
|
||||||
|
readChannel = JavaReadChannel(s.getInputStream())
|
||||||
|
writeChannel = JavaWriteChannel(s.getOutputStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openReadChannel(): ReadChannel = readChannel
|
||||||
|
override fun openWriteChannel(): WriteChannel = writeChannel
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (::socket.isInitialized && !socket.isClosed) {
|
||||||
|
runCatching { socket.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaReadChannel(private val inputStream: InputStream) : ReadChannel {
|
||||||
|
override suspend fun readByte(): Byte {
|
||||||
|
val b = inputStream.read()
|
||||||
|
if (b == -1) throw IllegalStateException("Socket stream reached EOF while reading byte")
|
||||||
|
return b.toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readBytes(length: Int): ByteArray {
|
||||||
|
val buffer = ByteArray(length)
|
||||||
|
readFullyInternal(buffer, 0, length)
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun readFully(out: ByteArray, start: Int, end: Int) {
|
||||||
|
readFullyInternal(out, start, end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFullyInternal(out: ByteArray, offset: Int, length: Int) {
|
||||||
|
var bytesRead = 0
|
||||||
|
while (bytesRead < length) {
|
||||||
|
val count = inputStream.read(out, offset + bytesRead, length - bytesRead)
|
||||||
|
if (count == -1) {
|
||||||
|
throw IllegalStateException("Socket stream closed unexpectedly (read $bytesRead of $length bytes)")
|
||||||
|
}
|
||||||
|
bytesRead += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class JavaWriteChannel(private val outputStream: OutputStream) : WriteChannel {
|
||||||
|
override suspend fun writeFully(value: ByteArray, startIndex: Int, endIndex: Int) {
|
||||||
|
outputStream.write(value, startIndex, endIndex - startIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flush() {
|
||||||
|
outputStream.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,6 @@ import cn.rtast.libmc.protocol.util.generateOfflineUuid
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.math.BigInteger
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.spec.X509EncodedKeySpec
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
@@ -31,43 +26,14 @@ class TestClientTestInJvm {
|
|||||||
val accessToken = File("src/jvmTest/resources/accessToken.txt").readText()
|
val accessToken = File("src/jvmTest/resources/accessToken.txt").readText()
|
||||||
private val chatTracker = ClientChatTracker()
|
private val chatTracker = ClientChatTracker()
|
||||||
|
|
||||||
fun encrypt(publicKeyBytes: ByteArray, data: ByteArray): ByteArray {
|
|
||||||
val keySpec = X509EncodedKeySpec(publicKeyBytes)
|
|
||||||
val keyFactory = KeyFactory.getInstance("RSA")
|
|
||||||
val publicKey = keyFactory.generatePublic(keySpec)
|
|
||||||
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
|
|
||||||
return cipher.doFinal(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun minecraftServerIdHash(serverId: String, secretKey: ByteArray, publicKey: ByteArray): String {
|
|
||||||
val serverIdBytes: ByteArray = serverId.encodeToByteArray()
|
|
||||||
for (b in serverIdBytes) {
|
|
||||||
require((b.toInt() and 0xFF) <= 0x7F) { "serverId contains non-US-ASCII character" }
|
|
||||||
}
|
|
||||||
val data = ByteArray(serverIdBytes.size + secretKey.size + publicKey.size)
|
|
||||||
System.arraycopy(serverIdBytes, 0, data, 0, serverIdBytes.size)
|
|
||||||
System.arraycopy(secretKey, 0, data, serverIdBytes.size, secretKey.size)
|
|
||||||
System.arraycopy(publicKey, 0, data, serverIdBytes.size + secretKey.size, publicKey.size)
|
|
||||||
val digest = MessageDigest.getInstance("SHA-1")
|
|
||||||
val hash = digest.digest(data)
|
|
||||||
return BigInteger(hash).toString(16)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test client`() {
|
fun `test client`() {
|
||||||
val cli = createMinecraftClient(
|
val cli = createMinecraftClient(
|
||||||
"127.0.0.1",
|
"127.0.0.1", 25565, "RTAkland",
|
||||||
25565,
|
|
||||||
"RTAkland",
|
|
||||||
// generateOfflineUuid("RTAkland"),
|
|
||||||
Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"),
|
Uuid.parse("bb033844-e68e-4909-a636-1a5d1821ddc4"),
|
||||||
// null,
|
|
||||||
accessToken,
|
accessToken,
|
||||||
context = DefaultProtocolContext
|
context = DefaultProtocolContext
|
||||||
)
|
)
|
||||||
// cli.onPacket<ClientboundSystemChatMessagePacket> { println(it) }
|
|
||||||
// cli.onPacket<ClientboundLoginSuccessPacket> { println(it) }
|
|
||||||
cli.on { packet, direction -> println("$direction -> $packet") }
|
cli.on { packet, direction -> println("$direction -> $packet") }
|
||||||
cli.launch { cli.connect() }
|
cli.launch { cli.connect() }
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -77,11 +43,8 @@ class TestClientTestInJvm {
|
|||||||
@Test
|
@Test
|
||||||
fun `test client offline mode`() {
|
fun `test client offline mode`() {
|
||||||
val cli = createMinecraftClient(
|
val cli = createMinecraftClient(
|
||||||
"127.0.0.1",
|
"127.0.0.1", 25566, "11",
|
||||||
25566,
|
generateOfflineUuid("11"), null,
|
||||||
"11",
|
|
||||||
generateOfflineUuid("11"),
|
|
||||||
null,
|
|
||||||
context = DefaultProtocolContext.withCustom {
|
context = DefaultProtocolContext.withCustom {
|
||||||
socketEngine = KtorNetworkEngine()
|
socketEngine = KtorNetworkEngine()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user