diff --git a/.gitignore b/.gitignore index 2838374..1a5ff25 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /run/ /*.iml /kotlin-js-store/ +/.kotlin/ diff --git a/build.gradle.kts b/build.gradle.kts index b111cf5..d6a392e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -91,9 +91,11 @@ kotlin { dependencies { implementation(libs.bundles.ktor.server) implementation(libs.bundles.ktor.client) + implementation(libs.ktor.network) implementation(libs.adventure.minimessage) implementation(libs.adventure.text.serializer.gson) + implementation(libs.adventure.text.serializer.legacy) implementation(libs.cache4k) implementation(libs.logback.classic) } diff --git a/docker-compose.yml b/docker-compose.yml index 0c1f31c..576df88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ services: - "traefik.http.routers.adventure-webui.tls.certresolver=httpOnly" - "traefik.http.routers.adventure-webui.tls.domains[0].main=webui.adventure.kyori.net" - "traefik.http.routers.adventure-webui.tls.domains[1].main=webui.advntr.dev" + - "traefik.tcp.services.adventure-webui-mc.loadbalancer.server.port=25565" + - "traefik.tcp.routers.adventure-webui-mc.rule=HostSNI(`*`)" + - "traefik.tcp.routers.adventure-webui-mc.entrypoints=minecraft" + - "traefik.tcp.routers.adventure-webui-mc.tls.passthrough=true" networks: - web diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 419fd4a..226efd5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ spotless = { id = "com.diffplug.spotless", version = "6.25.0" } [libraries] adventure-minimessage = { group = "net.kyori", name = "adventure-text-minimessage", version.ref = "adventure" } adventure-text-serializer-gson = { group = "net.kyori", name = "adventure-text-serializer-gson", version.ref = "adventure" } +adventure-text-serializer-legacy = { group = "net.kyori", name = "adventure-text-serializer-legacy", version.ref = "adventure" } cache4k = { group = "io.github.reactivecircus.cache4k", name = "cache4k", version = "0.13.0" } kotlinx-html = { group = "org.jetbrains.kotlinx", name = "kotlinx-html", version = "0.8.0" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.7.3" } @@ -24,6 +25,7 @@ ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.r ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" } ktor-server-caching-headers = { group = "io.ktor", name = "ktor-server-caching-headers", version.ref = "ktor" } ktor-server-compression = { group = "io.ktor", name = "ktor-server-compression", version.ref = "ktor" } +ktor-network = { group = "io.ktor", name = "ktor-network", version.ref = "ktor" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version = "1.5.8" } zKtlint = { group = "com.pinterest", name = "ktlint", version.ref = "ktlint"} diff --git a/src/commonMain/kotlin/net/kyori/adventure/webui/Constants.kt b/src/commonMain/kotlin/net/kyori/adventure/webui/Constants.kt index d7e1096..e4e4e30 100644 --- a/src/commonMain/kotlin/net/kyori/adventure/webui/Constants.kt +++ b/src/commonMain/kotlin/net/kyori/adventure/webui/Constants.kt @@ -42,5 +42,10 @@ public const val PARAM_EDITOR_TOKEN: String = "token" /** Path for getting a short link for a MiniMessage input. */ public const val URL_MINI_SHORTEN: String = "/mini-shorten" +/** Path for getting a hostname for an in-game MiniMessage motd preview. */ +public const val URL_SETUP_MOTD_PREVIEW: String = "/setup-motd-preview" +/** Path for getting a hostname for an in-game MiniMessage kick preview. */ +public const val URL_SETUP_KICK_PREVIEW: String = "/setup-kick-preview" + /** Path for getting the configuration of this WebUI instance */ public const val URL_BUILD_INFO: String = "/build" diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt index 17bbbe1..9464ddc 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/Application.kt @@ -50,6 +50,7 @@ public fun Application.main() { } } + /** Reads a string value from the `config` block in `application.conf`. */ public fun Application.getConfigString(key: String): String = environment.config.property("ktor.config.$key").getString() diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt index 0d32b2d..be9dcf1 100644 --- a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/MiniMessage.kt @@ -30,6 +30,8 @@ import net.kyori.adventure.webui.URL_MINI_SHORTEN import net.kyori.adventure.webui.URL_MINI_TO_HTML import net.kyori.adventure.webui.URL_MINI_TO_JSON import net.kyori.adventure.webui.URL_MINI_TO_TREE +import net.kyori.adventure.webui.URL_SETUP_KICK_PREVIEW +import net.kyori.adventure.webui.URL_SETUP_MOTD_PREVIEW import net.kyori.adventure.webui.jvm.appendComponent import net.kyori.adventure.webui.jvm.getConfigString import net.kyori.adventure.webui.jvm.minimessage.editor.installEditor @@ -42,6 +44,7 @@ import net.kyori.adventure.webui.jvm.minimessage.hook.INSERTION_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_COLOR_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_DECORATION_RENDER_HOOK import net.kyori.adventure.webui.jvm.minimessage.hook.TEXT_RENDER_HOOK +import net.kyori.adventure.webui.jvm.minimessage.preview.ServerStatusPreviewManager import net.kyori.adventure.webui.jvm.minimessage.storage.BytebinStorage import net.kyori.adventure.webui.tryDecodeFromString import net.kyori.adventure.webui.websocket.Call @@ -92,6 +95,9 @@ public fun Application.miniMessage() { BytebinStorage.BYTEBIN_INSTANCE = this.getConfigString("bytebinInstance") + // Initialise the server status preview manager. + val previewManager = ServerStatusPreviewManager(this) + routing { // define static path to resources static("") { @@ -199,6 +205,18 @@ public fun Application.miniMessage() { } } + post(URL_SETUP_MOTD_PREVIEW) { + val input = call.receiveText() + val hostname = previewManager.initializeMotdPreview(input) + call.respondText(hostname) + } + + post(URL_SETUP_KICK_PREVIEW) { + val input = call.receiveText() + val hostname = previewManager.initializeKickPreview(input) + call.respondText(hostname) + } + get(URL_BUILD_INFO) { val info = BuildInfo( startedAt = startedAt.toString(), diff --git a/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt new file mode 100644 index 0000000..1512593 --- /dev/null +++ b/src/jvmMain/kotlin/net/kyori/adventure/webui/jvm/minimessage/preview/ServerStatusPreviewManager.kt @@ -0,0 +1,218 @@ +package net.kyori.adventure.webui.jvm.minimessage.preview + +import io.github.reactivecircus.cache4k.Cache +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.server.application.Application +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.close +import io.ktor.utils.io.writeByte +import io.ktor.utils.io.writeFully +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer +import okhttp3.internal.and +import org.slf4j.LoggerFactory +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.hours + +/** Manager class for previewing server status. */ +public class ServerStatusPreviewManager( + application: Application, +) : CoroutineScope { + + private val logger = LoggerFactory.getLogger(ServerStatusPreviewManager::class.java) + private val managerJob = SupervisorJob(application.coroutineContext.job) + override val coroutineContext: CoroutineContext = application.coroutineContext + managerJob + + private val motdPreviews = Cache.Builder().expireAfterAccess(1.hours).build() + private val kickPreviews = Cache.Builder().expireAfterAccess(1.hours).build() + + init { + launch { + // Initialise the socket. + val serverSocket = aSocket(SelectorManager(Dispatchers.IO)).tcp().bind("0.0.0.0", 25565) + logger.info("Listening for pings at ${serverSocket.localAddress}") + + while (true) { + // Ensure we are active so that the socket is properly closed when the application ends. + ensureActive() + + val socket = serverSocket.accept() + logger.debug("Accepted socket connection from {}", socket.remoteAddress) + + launch { + try { + val receiveChannel = socket.openReadChannel() + val sendChannel = socket.openWriteChannel(autoFlush = true) + + // handshake + val handshakePacket = receiveChannel.readMcPacket() + val protocolVersion = handshakePacket.readVarInt() + val serverAddress = handshakePacket.readUtf8String() + val serverPort = handshakePacket.readShort() + val nextState = handshakePacket.readVarInt() + + if (nextState != 1) { + // send kick + sendChannel.writeMcPacket(0) { + it.writeString( + GsonComponentSerializer.gson() + .serialize(MiniMessage.miniMessage().deserialize(lookupKickMessage(serverAddress))) + ) + } + } else { + // send status response + sendChannel.writeMcPacket(0) { + it.writeString( + """{ + "version": { + "name": "${ + LegacyComponentSerializer.legacySection() + .serialize(MiniMessage.miniMessage().deserialize("MiniMessage")) + }", + "protocol": 1 + }, + "players": { + "max": 0, + "online": 0 + }, + "description": ${ + GsonComponentSerializer.gson().serialize( + MiniMessage.miniMessage().deserialize(lookupMotd(serverAddress)) + ) + } + }""".trimIndent() + ) + } + } + + sendChannel.close() + } catch (e: Exception) { + logger.error("An unknown error occurred whilst responding to a ping from ${socket.remoteAddress}", e) + } + + socket.close() + } + } + } + } + + private fun lookupKickMessage(serverAddress: String): String { + return kickPreviews.get(serverAddress.split("\\.")[0]) ?: "You cant join here!" + } + + private fun lookupMotd(serverAddress: String): String { + return motdPreviews.get(serverAddress.split("\\.")[0]) ?: "MiniMessage is cool!" + } + + public fun initializeKickPreview(input: String): String { + val key = generateRandomString() + kickPreviews.put(key, input) + return "$key.webui.advntr.dev" + } + + public fun initializeMotdPreview(input: String): String { + val key = generateRandomString() + motdPreviews.put(key, input) + return "$key.webui.advntr.dev" + } + + private fun generateRandomString(length: Int = 8): String { + val allowedChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + return (1..length) + .map { allowedChars.random() } + .joinToString("") + } + + private suspend fun ByteWriteChannel.writeMcPacket(packetId: Int, consumer: (packet: DataOutputStream) -> Unit) { + val stream = ByteArrayOutputStream() + val packet = DataOutputStream(stream) + + consumer.invoke(packet) + + val data = stream.toByteArray() + writeVarInt(data.size + 1) + writeVarInt(packetId) + writeFully(data) + } + + private fun DataOutputStream.writeString(string: String) { + val bytes = string.toByteArray(Charsets.UTF_8) + writeVarInt(bytes.size) + write(bytes) + } + + private fun DataOutputStream.writeVarInt(int: Int) { + var value = int + while (true) { + if ((value and 0x7F.inv()) == 0) { + writeByte(value) + return + } + + writeByte((value and 0x7F) or 0x80) + + value = value ushr 7 + } + } + + private suspend fun ByteWriteChannel.writeVarInt(int: Int) { + var value = int + while (true) { + if ((value and 0x7F.inv()) == 0) { + writeByte(value) + return + } + + writeByte((value and 0x7F) or 0x80) + + value = value ushr 7 + } + } + + private suspend fun ByteReadChannel.readMcPacket(): ByteReadChannel { + val length = readVarInt() + val packetId = readVarInt() + val data = ByteArray(length) + readFully(data, 0, length) + return ByteReadChannel(data) + } + + private suspend fun ByteReadChannel.readVarInt(): Int { + var value = 0 + var position = 0 + var currentByte: Byte + + while (true) { + currentByte = readByte() + value = value or ((currentByte and 0x7F) shl position) + + if ((currentByte and 0x80) == 0) break + + position += 7 + + if (position >= 32) throw RuntimeException("VarInt is too big") + } + + return value + } + + private suspend fun ByteReadChannel.readUtf8String(): String { + val length = readVarInt() + val data = ByteArray(length) + readFully(data, 0, length) + return String(data) + } +}