From c15ab6595e3dd368901a92c05604874ab30c8abe Mon Sep 17 00:00:00 2001 From: yenon Date: Thu, 7 Dec 2023 18:57:00 +0100 Subject: [PATCH] Rcon rework with timeouts, skeleton implementation of sftp log reader. --- .idea/kotlinc.xml | 2 +- build.gradle.kts | 4 +- src/main/kotlin/LayerInfo.kt | 0 src/main/kotlin/Main.kt | 124 ----------- src/main/kotlin/RconConnection.kt | 182 --------------- .../kotlin/{ => yenon/squadcompanion}/Css.kt | 11 +- .../squadcompanion}/LayerInfoModule.kt | 2 + src/main/kotlin/yenon/squadcompanion/Main.kt | 140 ++++++++++++ .../{ => yenon/squadcompanion}/Module.kt | 2 + .../squadcompanion}/OverviewTemplate.kt | 43 ++-- .../squadcompanion}/RconAbstraction.kt | 49 ++-- .../yenon/squadcompanion/RconConnection.kt | 210 ++++++++++++++++++ .../squadcompanion/logreader/LogReaderSftp.kt | 46 ++++ .../logreader/LogReaderTail.kt} | 6 +- src/test/kotlin/WatchTest.kt | 3 +- 15 files changed, 470 insertions(+), 354 deletions(-) delete mode 100644 src/main/kotlin/LayerInfo.kt delete mode 100644 src/main/kotlin/Main.kt delete mode 100644 src/main/kotlin/RconConnection.kt rename src/main/kotlin/{ => yenon/squadcompanion}/Css.kt (75%) rename src/main/kotlin/{ => yenon/squadcompanion}/LayerInfoModule.kt (97%) create mode 100644 src/main/kotlin/yenon/squadcompanion/Main.kt rename src/main/kotlin/{ => yenon/squadcompanion}/Module.kt (93%) rename src/main/kotlin/{ => yenon/squadcompanion}/OverviewTemplate.kt (65%) rename src/main/kotlin/{ => yenon/squadcompanion}/RconAbstraction.kt (81%) create mode 100644 src/main/kotlin/yenon/squadcompanion/RconConnection.kt create mode 100644 src/main/kotlin/yenon/squadcompanion/logreader/LogReaderSftp.kt rename src/main/kotlin/{LogReader.kt => yenon/squadcompanion/logreader/LogReaderTail.kt} (92%) diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index e805548..ae3f30a 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 49a982c..b60f160 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - kotlin("jvm") version "1.9.20" + kotlin("jvm") version "1.9.21" } group = "org.example" @@ -18,6 +18,8 @@ dependencies { implementation("io.ktor:ktor-server-default-headers-jvm:2.3.6") implementation("io.ktor:ktor-server-html-builder:2.3.6") implementation("org.jetbrains.kotlin-wrappers:kotlin-css:1.0.0-pre.650") + + implementation("com.jcraft:jsch:0.1.55") } tasks.test { diff --git a/src/main/kotlin/LayerInfo.kt b/src/main/kotlin/LayerInfo.kt deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt deleted file mode 100644 index 9c35bab..0000000 --- a/src/main/kotlin/Main.kt +++ /dev/null @@ -1,124 +0,0 @@ -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.engine.* -import io.ktor.server.html.* -import io.ktor.server.netty.* -import io.ktor.server.plugins.statuspages.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import java.io.PrintWriter -import java.io.StringWriter -import java.nio.file.Paths -import java.text.SimpleDateFormat - -val squadTimeFormat = SimpleDateFormat("yyyy.MM.dd-HH.mm.ss:SSS") -val logRegex = Regex("""^\[(\d{4}.\d{2}.\d{2}-\d{2}.\d{2}.\d{2}:\d{3})]\[([\d ]{3})](.*)$""") - -val modules = arrayListOf(LayerInfoModule) - -fun main() { - LogReader(Paths.get("SquadGame.log")) { logLine -> - logRegex.find(logLine)?.let { matchResult -> - val time = squadTimeFormat.parse(matchResult.groupValues[1]) - val id = matchResult.groupValues[2].trim().toShort() - val message = matchResult.groupValues[3] - - modules.forEach { - if (it.enabled) { - it.onLogMessage(time, id, message) - } - } - } - } - - val connection = RconConnection("195.201.62.238", 21114, "ThisIsJustATestServer") - val abstraction = RconAbstraction(connection) - GlobalScope.launch { - - //if(connection.reconnect().isFailure){ - // println("No connection :(") - //} - //println(connection.sendCommand("ListLayers")) - } - - embeddedServer(Netty, 8080) { - install(StatusPages) { - exception { call, cause -> - val writer = StringWriter() - cause.printStackTrace(PrintWriter(writer)) - call.respondText(text = "500: $writer", status = HttpStatusCode.InternalServerError) - } - } - routing { - get("/layers") { - call.respondText( - contentType = ContentType.Text.Plain, - text = "Count: " + LayerInfoModule.layerList.size + "\n\n" + - LayerInfoModule.layerList.joinToString("\n") - ) - } - get("/rotation") { - call.respondText( - contentType = ContentType.Text.Plain, - text = "Count: " + LayerInfoModule.rotationList.size + "\n\n" + - LayerInfoModule.rotationList.joinToString("\n") - ) - } - get("/currentPlayers") { - call.respondText( - contentType = ContentType.Text.Plain, - text = abstraction.getCurrentPlayers().toString() - ) - } - get("/overview") { - val players = abstraction.getCurrentPlayers() - val squadInfo = abstraction.getSquadList() - - call.respondHtmlTemplate(OverviewTemplate(players, squadInfo)) {} - } - get("/overviewDemo") { - //val players = abstraction.getCurrentPlayers() - //val squadInfo = abstraction.getSquadList() - val players = ListPlayersOutput( - arrayListOf( - Player(1, 86868686868686868L, "newSlMan", 1, 1, true, "SL"), - Player(2, 31337313373133711L, "IStayed(TM)", 2, 1, true, "SL"), - Player(1, 1L, "Man1", 1, 1, false, "RM"), - Player(1, 2L, "Man2", 1, 1, false, "LAT"), - Player(1, 3L, "Man3", 1, 1, false, "HAT"), - Player(1, 4L, "Man4", 1, 1, false, "MED"), - Player(1, 5L, "Man5", 1, 1, false, "GR"), - Player(1, 6L, "OtherSL1", 2, 2, true, "SL"), - Player(1, 7L, "OtherMan2", 2, 2, false, "MED"), - Player(1, 8L, "OtherMan3", 2, 0, false, "RM"), - Player(1, 9L, "OtherMan4", 2, 0, false, "RM"), - Player(1, 10L, "OtherMan5", 2, 0, false, "RM"), - ), arrayListOf() - ) - - val squadInfo = Pair( - RconAbstraction.SquadListFaction( - "1st Battalion, 1st Marines", hashMapOf( - 1 to RconAbstraction.SquadListSquad("HERPS", 5, true, "leaver9000", 69696969696969696L) - ) - ), - RconAbstraction.SquadListFaction( - "Insurgent Rebel Federation", hashMapOf( - 1 to RconAbstraction.SquadListSquad("DERPS", 1, true, "IStayed(TM)", 31337313373133711L), - 2 to RconAbstraction.SquadListSquad("NOOBS WELCOME", 2, false, "OtherSL1", 6L) - ) - ) - ) - - call.respondHtmlTemplate(OverviewTemplate(players, squadInfo)) { - - } - } - get("/css") { - call.respondText(contentType = ContentType.Text.CSS, text = Css.cssString) - } - } - }.start(true) -} \ No newline at end of file diff --git a/src/main/kotlin/RconConnection.kt b/src/main/kotlin/RconConnection.kt deleted file mode 100644 index cd6e3ec..0000000 --- a/src/main/kotlin/RconConnection.kt +++ /dev/null @@ -1,182 +0,0 @@ -import io.ktor.utils.io.core.* -import io.ktor.utils.io.nio.* -import kotlinx.coroutines.* -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.net.InetSocketAddress -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.nio.channels.SocketChannel -import java.util.* -import kotlin.text.toByteArray - -const val SERVERDATA_AUTH = 3 -const val SERVERDATA_AUTH_RESPONSE = 2 -const val SERVERDATA_EXECCOMMAND = 2 -const val SERVERDATA_RESPONSE_VALUE = 0 - -const val FAILED_AUTH_ID = -1 -const val ACCEPTED_AUTH_ID = 1 -const val SQUAD_CONSOLE_ID = 0 - -val rconCharset = Charsets.UTF_8 - -data class RconPacket(val id: Int, val type: Int, val data: ByteArray) - -class RconConnection(private val host: String, private val port: Int, password: String) : AutoCloseable { - - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private val authPacket = - createPacketByteBuffer(RconPacket(ACCEPTED_AUTH_ID, SERVERDATA_AUTH, password.toByteArray(rconCharset))) - private var currentId = ACCEPTED_AUTH_ID + 1 - private var socketChannel: SocketChannel = SocketChannel.open() - private val mutex = Mutex() - - init { - coroutineScope.launch { - while (isActive) { - delay(5000) - sendCommand("ListPlayers") - //println("Keepalive: "+sendCommand("ListPlayers")) - } - } - } - - private suspend fun reconnect(): Result = withContext(Dispatchers.IO) { - socketChannel.close() - socketChannel = SocketChannel.open().apply { - connect(InetSocketAddress(host, port)) - finishConnect() - println("Socket is open.") - } - - authenticate() - } - - private fun authenticate(): Result { - println("Sending auth packet") - socketChannel.write(authPacket) - println("Auth sent") - readPacket() - val packet = readPacket() - println("Response: $packet") - return if (packet.type == SERVERDATA_AUTH_RESPONSE) { - println(packet.id == ACCEPTED_AUTH_ID) - Result.success(packet.id == ACCEPTED_AUTH_ID) - } else { - Result.success(false) - } - } - - suspend fun sendCommand(command: String): Result = withContext(Dispatchers.IO) { - mutex.withLock { - if (!socketChannel.isConnected) { - println("reconnect needed.") - val reconnectResult = reconnect() - if (reconnectResult.isFailure) { - return@withContext Result.failure(reconnectResult.exceptionOrNull()!!) - } - reconnectResult.getOrNull()?.let { - if (!it) { - return@withContext Result.failure(Exception("Wrong password.")) - } - } - } - - currentId++ - val commandPacket = - createPacketByteBuffer(RconPacket(currentId, SERVERDATA_EXECCOMMAND, command.toByteArray(rconCharset))) - socketChannel.write(commandPacket) - - var responsePacket = readPacket() - //while (responsePacket.id() - do { - packetByteList.add(responsePacket.data) - responsePacket = readPacket() - } while (responsePacket.data.isEmpty()) - - if (responsePacket.id != currentId) { - return@withContext Result.failure(Exception("Out of order packet.")) - } - - val totalBytes = packetByteList.sumOf { it.size } - val combinedBytes = ByteArray(totalBytes) - var start = 0 - - packetByteList.forEach { - it.copyInto(combinedBytes, start) - start += it.size - } - - return@withContext Result.success(String(combinedBytes, rconCharset)) - } - } - - private fun readPacket(): RconPacket { - var packet = readRawPacket() - while (packet.id == 0 && packet.type == 1) { - println("Console ${packet.data.size}: " + String(packet.data)) - packet = readRawPacket() - } - return packet - } - - private fun readRawPacket(): RconPacket { - print("Reading new packet") - val size = socketChannel.readPacketExact(4L).readIntLittleEndian() - println(", size: $size") - val packet = socketChannel.readPacketExact(size.toLong()) - - val id = packet.readIntLittleEndian() - val type = packet.readIntLittleEndian() - val data = packet.readBytes(size - 10) - println("Read packet with id: $id, type: $type") - - return RconPacket(id, type, data) - } - - private fun createPacketByteBuffer(packet: RconPacket): ByteBuffer { - val length = 4 + 4 + 4 + packet.data.size + 2 - val buffer = ByteBuffer.allocate(length) - buffer.order(ByteOrder.LITTLE_ENDIAN) - - buffer.putInt(length - 4) - buffer.putInt(packet.id) - buffer.putInt(packet.type) - - buffer.put(packet.data) - - //rcon needs 0 terminated string with additional 0 as end of packet - buffer.put(0) - buffer.put(0) - - return buffer.flip() as ByteBuffer - } - - override fun close() { - socketChannel.close() - } -} \ No newline at end of file diff --git a/src/main/kotlin/Css.kt b/src/main/kotlin/yenon/squadcompanion/Css.kt similarity index 75% rename from src/main/kotlin/Css.kt rename to src/main/kotlin/yenon/squadcompanion/Css.kt index bcbf2a0..7a72b49 100644 --- a/src/main/kotlin/Css.kt +++ b/src/main/kotlin/yenon/squadcompanion/Css.kt @@ -1,3 +1,5 @@ +package yenon.squadcompanion + object Css { val cssString = """ .teamView{ @@ -6,6 +8,7 @@ object Css { display: flex; } .team{ + border-radius: 20px; width: 50%; margin: 6px; padding: 6px; @@ -17,14 +20,18 @@ object Css { background-color: #ffbbbb; } .squad{ - background-color: #00000020; margin: 6px; } .squadTable{ + background-color: #00000020; + border-radius: 0px 0px 5px 5px; width: 100%; + border: none; } .squadHeader{ - background-color: #00000050 + padding-left: 10px; + background-color: #00000050; + border-radius: 5px 5px 0px 0px; } .leader{ background-color: #CD7F32 diff --git a/src/main/kotlin/LayerInfoModule.kt b/src/main/kotlin/yenon/squadcompanion/LayerInfoModule.kt similarity index 97% rename from src/main/kotlin/LayerInfoModule.kt rename to src/main/kotlin/yenon/squadcompanion/LayerInfoModule.kt index 4e0e8e4..e46d1de 100644 --- a/src/main/kotlin/LayerInfoModule.kt +++ b/src/main/kotlin/yenon/squadcompanion/LayerInfoModule.kt @@ -1,3 +1,5 @@ +package yenon.squadcompanion + import java.util.* object LayerInfoModule : Module() { diff --git a/src/main/kotlin/yenon/squadcompanion/Main.kt b/src/main/kotlin/yenon/squadcompanion/Main.kt new file mode 100644 index 0000000..b1638a1 --- /dev/null +++ b/src/main/kotlin/yenon/squadcompanion/Main.kt @@ -0,0 +1,140 @@ +package yenon.squadcompanion + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.html.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import yenon.squadcompanion.logreader.LogReaderTail +import java.io.PrintWriter +import java.io.StringWriter +import java.nio.file.Paths +import java.text.SimpleDateFormat + +object Main { + private val squadTimeFormat = SimpleDateFormat("yyyy.MM.dd-HH.mm.ss:SSS") + private val logRegex = Regex("""^\[(\d{4}.\d{2}.\d{2}-\d{2}.\d{2}.\d{2}:\d{3})]\[([\d ]{3})](.*)$""") + + private val modules = arrayListOf(LayerInfoModule) + + @JvmStatic + fun main(vararg args: String) { + LogReaderTail(Paths.get("SquadGame.log")) { logLine -> + logRegex.find(logLine)?.let { matchResult -> + val time = squadTimeFormat.parse(matchResult.groupValues[1]) + val id = matchResult.groupValues[2].trim().toShort() + val message = matchResult.groupValues[3] + + modules.forEach { + if (it.enabled) { + it.onLogMessage(time, id, message) + } + } + } + } + + val connection = RconConnection("195.201.62.238", 21114, "ThisIsJustATestServer") + val abstraction = RconAbstraction(connection) + + embeddedServer(Netty, 8080) { + install(StatusPages) { + exception { call, cause -> + val writer = StringWriter() + cause.printStackTrace(PrintWriter(writer)) + call.respondText(text = "500: $writer", status = HttpStatusCode.InternalServerError) + } + } + routing { + get("/layers") { + call.respondText( + contentType = ContentType.Text.Plain, + text = "Count: " + LayerInfoModule.layerList.size + "\n\n" + + LayerInfoModule.layerList.joinToString("\n") + ) + } + get("/rotation") { + call.respondText( + contentType = ContentType.Text.Plain, + text = "Count: " + LayerInfoModule.rotationList.size + "\n\n" + + LayerInfoModule.rotationList.joinToString("\n") + ) + } + get("/currentPlayers") { + call.respondText( + contentType = ContentType.Text.Plain, + text = abstraction.getCurrentPlayers().toString() + ) + } + get("/overview") { + val players = abstraction.getCurrentPlayers() + val squadInfo = abstraction.getSquadList() + + call.respondHtmlTemplate(OverviewTemplate(players, squadInfo)) {} + } + get("/overviewDemo") { + val players = RconAbstraction.ListPlayersOutput( + arrayListOf( + RconAbstraction.Player(1, 86868686868686868L, "newSlMan", 1, 1, true, "SL"), + RconAbstraction.Player(2, 31337313373133711L, "IStayed(TM)", 2, 1, true, "SL"), + RconAbstraction.Player(1, 1L, "Man1", 1, 1, false, "RM"), + RconAbstraction.Player(1, 2L, "Man2", 1, 1, false, "LAT"), + RconAbstraction.Player(1, 3L, "Man3", 1, 1, false, "HAT"), + RconAbstraction.Player(1, 4L, "Man4", 1, 1, false, "MED"), + RconAbstraction.Player(1, 5L, "Man5", 1, 1, false, "GR"), + RconAbstraction.Player(1, 6L, "OtherSL1", 2, 2, true, "SL"), + RconAbstraction.Player(1, 7L, "OtherMan2", 2, 2, false, "MED"), + RconAbstraction.Player(1, 8L, "OtherMan3", 2, 0, false, "RM"), + RconAbstraction.Player(1, 9L, "OtherMan4", 2, 0, false, "RM"), + RconAbstraction.Player(1, 10L, "OtherMan5", 2, 0, false, "RM"), + ), arrayListOf() + ) + + val squadInfo = Pair( + RconAbstraction.SquadListFaction( + "1st Battalion, 1st Marines", hashMapOf( + 1 to RconAbstraction.SquadListSquad("HERPS", 5, true, "leaver9000", 69696969696969696L) + ) + ), + RconAbstraction.SquadListFaction( + "Insurgent Rebel Federation", hashMapOf( + 1 to RconAbstraction.SquadListSquad( + "DERPS", + 1, + true, + "IStayed(TM)", + 31337313373133711L + ), + 2 to RconAbstraction.SquadListSquad("NOOBS WELCOME", 2, false, "OtherSL1", 6L) + ) + ) + ) + + call.respondHtmlTemplate(OverviewTemplate(players, squadInfo)) {} + } + get("/css") { + call.respondText(contentType = ContentType.Text.CSS, text = Css.cssString) + } + post("/kick") { + val playerId = call.request.queryParameters["id"]!!.toLong() + val reason = call.receiveText() + abstraction.kickPlayer(playerId, reason) + } + post("/ban") { + val playerId = call.request.queryParameters["id"]!!.toLong() + val time = call.request.queryParameters["time"]!! + val reason = call.receiveText() + abstraction.banPlayer(playerId, time, reason) + } + post("/message") { + val playerId = call.request.queryParameters["id"]!!.toLong() + val message = call.receiveText() + abstraction.messagePlayer(playerId, message) + } + } + }.start(true) + } +} \ No newline at end of file diff --git a/src/main/kotlin/Module.kt b/src/main/kotlin/yenon/squadcompanion/Module.kt similarity index 93% rename from src/main/kotlin/Module.kt rename to src/main/kotlin/yenon/squadcompanion/Module.kt index c3b99a9..3436e18 100644 --- a/src/main/kotlin/Module.kt +++ b/src/main/kotlin/yenon/squadcompanion/Module.kt @@ -1,3 +1,5 @@ +package yenon.squadcompanion + import java.util.* abstract class Module { diff --git a/src/main/kotlin/OverviewTemplate.kt b/src/main/kotlin/yenon/squadcompanion/OverviewTemplate.kt similarity index 65% rename from src/main/kotlin/OverviewTemplate.kt rename to src/main/kotlin/yenon/squadcompanion/OverviewTemplate.kt index fdf873b..d7eb55e 100644 --- a/src/main/kotlin/OverviewTemplate.kt +++ b/src/main/kotlin/yenon/squadcompanion/OverviewTemplate.kt @@ -1,11 +1,13 @@ +package yenon.squadcompanion + import io.ktor.server.html.* import kotlinx.html.* class OverviewTemplate( - private val players: ListPlayersOutput, + private val players: RconAbstraction.ListPlayersOutput, private val squadInfo: Pair ) : Template { - private fun TR.makePlayer(player: Player) { + private fun TR.makePlayer(player: RconAbstraction.Player) { td { +player.role } td { +player.name } td { +player.steamId.toString() } @@ -23,24 +25,28 @@ class OverviewTemplate( } private fun FlowContent.makeSquad( - squad: Map.Entry>, - squadListSquad: RconAbstraction.SquadListSquad + squadMembers: Map.Entry>, + squad: RconAbstraction.SquadListSquad? ) { - val leader = squad.value.find { it.leader } + val leader = squadMembers.value.find { it.leader } div("squad") { div("squadHeader") { - if (squad.key != 0) { - +squadListSquad.name + if (squad == null) { + +"Squad info not available, refresh page." } else { - +"Unassigned" - } + if (squadMembers.key != 0) { + +squad.name + } else { + +"Unassigned" + } - if (squadListSquad.locked) { - +" \uD83D\uDD12" - } + if (squad.locked) { + +" \uD83D\uDD12" + } - if (squadListSquad.creatorSteamId != (leader?.steamId ?: 0) && squadListSquad.creatorSteamId != 0L) { - +" (originally created by ${squadListSquad.creatorName} ${squadListSquad.creatorSteamId})" + if (squad.creatorSteamId != (leader?.steamId ?: 0) && squad.creatorSteamId != 0L) { + +" (originally created by ${squad.creatorName} ${squad.creatorSteamId})" + } } } @@ -58,7 +64,7 @@ class OverviewTemplate( } } } - squad.value.forEach { + squadMembers.value.forEach { if (!it.leader) { tr { makePlayer(it) @@ -69,7 +75,10 @@ class OverviewTemplate( } } - private fun FlowContent.makeSide(team: Map>, squadListFaction: RconAbstraction.SquadListFaction) { + private fun FlowContent.makeSide( + team: Map>, + squadListFaction: RconAbstraction.SquadListFaction + ) { div("teamHeader") { +squadListFaction.name } @@ -79,7 +88,7 @@ class OverviewTemplate( } team.forEach { if (it.key != 0) { - makeSquad(it, squadListFaction.squads[it.key]!!) + makeSquad(it, squadListFaction.squads[it.key]) } } team.filter { it.key == 0 }.forEach { diff --git a/src/main/kotlin/RconAbstraction.kt b/src/main/kotlin/yenon/squadcompanion/RconAbstraction.kt similarity index 81% rename from src/main/kotlin/RconAbstraction.kt rename to src/main/kotlin/yenon/squadcompanion/RconAbstraction.kt index e50132a..0db41e8 100644 --- a/src/main/kotlin/RconAbstraction.kt +++ b/src/main/kotlin/yenon/squadcompanion/RconAbstraction.kt @@ -1,28 +1,29 @@ - - -data class ListPlayersOutput(val players: ArrayList, val disconnected: ArrayList) -data class Player( - val playerId: Int, - val steamId: Long, - val name: String, - val teamId: Int, - val squadId: Int, - val leader: Boolean, - val role: String -) - -data class DisconnectedPlayer(val playerId: Int, val steamId: Long, val time: String, val name: String) - -val activePlayerInListRegex = Regex( - """ID: (\d+) \| SteamID: (\d{17}) \| Name: (.*) \| Team ID: ([12]) \| Squad ID: (N/A|\d+) \| Is Leader: (False|True) \| Role: ([a-zA-Z0-9_]+)${'$'}""", - RegexOption.MULTILINE -) -val inactivePlayerInListRegex = Regex( - """ID: (\d+) \| SteamID: (\d{17}) \| Since Disconnect: (\d{2}m\.\d{2})s \| Name: (.*)${'$'}""", - RegexOption.MULTILINE -) +package yenon.squadcompanion class RconAbstraction(private val connection: RconConnection) { + companion object { + val activePlayerInListRegex = Regex( + """ID: (\d+) \| SteamID: (\d{17}) \| Name: (.*) \| Team ID: ([12]) \| Squad ID: (N/A|\d+) \| Is Leader: (False|True) \| Role: ([a-zA-Z0-9_]+)${'$'}""", + RegexOption.MULTILINE + ) + val inactivePlayerInListRegex = Regex( + """ID: (\d+) \| SteamID: (\d{17}) \| Since Disconnect: (\d{2}m\.\d{2})s \| Name: (.*)${'$'}""", + RegexOption.MULTILINE + ) + } + + data class ListPlayersOutput(val players: ArrayList, val disconnected: ArrayList) + data class Player( + val playerId: Int, + val steamId: Long, + val name: String, + val teamId: Int, + val squadId: Int, + val leader: Boolean, + val role: String + ) + + data class DisconnectedPlayer(val playerId: Int, val steamId: Long, val time: String, val name: String) /* Format for "ListPlayers" @@ -79,7 +80,7 @@ class RconAbstraction(private val connection: RconConnection) { val teamRegex = Regex("""^Team ID: ([12]) \(([^)]+)\)$""") val squadRegex = - Regex("""^ID: (\d+) \| Name: (.*) \| Size: (\d+) \| Locked: (True|False) \| Creator Name: (.*) \| Creator Steam ID: (\d{17})${'$'}""") + Regex("""^ID: (\d+) \| Name: (.*) \| Size: (\d+) \| Locked: (True|False) \| Creator Name: (.*) \| Creator Steam ID: (\d{17})$""") lateinit var faction1: SquadListFaction lateinit var faction2: SquadListFaction diff --git a/src/main/kotlin/yenon/squadcompanion/RconConnection.kt b/src/main/kotlin/yenon/squadcompanion/RconConnection.kt new file mode 100644 index 0000000..db2497a --- /dev/null +++ b/src/main/kotlin/yenon/squadcompanion/RconConnection.kt @@ -0,0 +1,210 @@ +package yenon.squadcompanion + +import io.ktor.utils.io.core.* +import io.ktor.utils.io.nio.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.SocketChannel +import java.util.* +import kotlin.text.toByteArray +import kotlin.time.Duration.Companion.milliseconds + +class RconConnection(private val host: String, private val port: Int, password: String) : AutoCloseable { + companion object { + const val TYPE_AUTH = 3 + const val TYPE_AUTH_RESPONSE = 2 + const val TYPE_EXECCOMMAND = 2 + const val TYPE_RESPONSE_VALUE = 0 + + const val ID_AUTH_ACCEPTED = 1 + const val ID_AUTH_REJECTED = -1 + const val ID_SQUAD_CONSOLE = 0 + + val rconCharset = Charsets.UTF_8 + } + + data class RconPacket(val id: Int, val type: Int, val data: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RconPacket + + if (id != other.id) return false + if (type != other.type) return false + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + type + result = 31 * result + data.contentHashCode() + return result + } + } + + private val coroutineScope = CoroutineScope(Dispatchers.IO) + private val authPacket = + createPacketByteBuffer(RconPacket(ID_AUTH_ACCEPTED, TYPE_AUTH, password.toByteArray(rconCharset))) + private var currentId = ID_AUTH_ACCEPTED + 1 + private var socketChannel: SocketChannel = SocketChannel.open() + private val mutex = Mutex() + + private val sameIdPacketList = LinkedList() + private val packetsFlow = MutableSharedFlow(extraBufferCapacity = 8) + + init { + coroutineScope.launch { + while (isActive) { + delay(5000) + sendCommand("ListPlayers") + println("Keepalive: " + sendCommand("ListPlayers")) + } + } + coroutineScope.launch { + while (isActive) { + if (!socketChannel.isConnected) { + reconnect() + } + try { + var packet = readRawPacket() + val firstId = packet.id + sameIdPacketList.add(packet) + while (packet.data.size == 4084) { + packet = readRawPacket() + if (packet.id != firstId) { + println("Out of order packet during assembly.") + println("Data: $packet") + println("Packet has been discarded.") + break + } + sameIdPacketList.add(packet) + } + if (sameIdPacketList.size == 1) { + packetsFlow.emit(sameIdPacketList[0]) + } else { + val totalByteCount = sameIdPacketList.sumOf { it.data.size } + val combinedByteArray = ByteArray(totalByteCount) + var start = 0 + + sameIdPacketList.forEach { + it.data.copyInto(combinedByteArray, start) + start += it.data.size + } + val initialPacket = sameIdPacketList.first + val combinedPacket = RconPacket(initialPacket.id, initialPacket.type, combinedByteArray) + packetsFlow.emit(combinedPacket) + } + } catch (ex: Exception) { + ex.printStackTrace() + } + sameIdPacketList.clear() + } + } + } + + private suspend fun reconnect(): Boolean = withContext(Dispatchers.IO) { + socketChannel.close() + socketChannel = SocketChannel.open().apply { + connect(InetSocketAddress(host, port)) + finishConnect() + println("Socket is open.") + } + + authenticate() + } + + private suspend fun authenticate(): Boolean { + println("Sending auth packet") + + withContext(Dispatchers.IO) { + socketChannel.write(authPacket) + println("Auth sent") + } + + println("Waiting for auth response") + //Squad will send 2 packets, we will treat them as random order, even though that should never happen... + val authResponse = arrayOf( + readRawPacket(), readRawPacket() + ).firstOrNull { it.type == TYPE_AUTH_RESPONSE } + + return (authResponse?.id ?: ID_AUTH_REJECTED) == ID_AUTH_ACCEPTED + } + + class SquadBullshitException : Exception("No empty commands in squad rcon, a faulty response will clog the stream.") + + suspend fun sendCommand(command: String): Result = withContext(Dispatchers.IO) { + if (command.isEmpty()) { + throw SquadBullshitException() + } + + mutex.withLock { + val incomingPackets = packetsFlow.asSharedFlow().timeout(5000.milliseconds).catch {} + currentId++ + val commandPacket = + createPacketByteBuffer(RconPacket(currentId, TYPE_EXECCOMMAND, command.toByteArray(rconCharset))) + socketChannel.write(commandPacket) + + val packet = incomingPackets.firstOrNull { it.id == currentId } + + packet?.let { + return@withContext Result.success(String(it.data, rconCharset)) + } + + return@withContext Result.failure(Exception("No Response")) + + } + } + + private fun readPacket(): RconPacket { + var packet = readRawPacket() + while (packet.id == 0 && packet.type == 1) { + println("Console ${packet.data.size}: " + String(packet.data)) + packet = readRawPacket() + } + return packet + } + + private fun readRawPacket(): RconPacket { + print("Reading new packet") + val size = socketChannel.readPacketExact(4L).readIntLittleEndian() + println(", size: $size") + val packet = socketChannel.readPacketExact(size.toLong()) + + val id = packet.readIntLittleEndian() + val type = packet.readIntLittleEndian() + val data = packet.readBytes(size - 10) + println("Read packet with id: $id, type: $type") + + return RconPacket(id, type, data) + } + + private fun createPacketByteBuffer(packet: RconPacket): ByteBuffer { + val length = 4 + 4 + 4 + packet.data.size + 2 + val buffer = ByteBuffer.allocate(length) + buffer.order(ByteOrder.LITTLE_ENDIAN) + + buffer.putInt(length - 4) + buffer.putInt(packet.id) + buffer.putInt(packet.type) + + buffer.put(packet.data) + + //rcon needs 0 terminated string with additional 0 as end of packet + buffer.put(0) + buffer.put(0) + + return buffer.flip() as ByteBuffer + } + + override fun close() { + socketChannel.close() + } +} \ No newline at end of file diff --git a/src/main/kotlin/yenon/squadcompanion/logreader/LogReaderSftp.kt b/src/main/kotlin/yenon/squadcompanion/logreader/LogReaderSftp.kt new file mode 100644 index 0000000..9d2b8e9 --- /dev/null +++ b/src/main/kotlin/yenon/squadcompanion/logreader/LogReaderSftp.kt @@ -0,0 +1,46 @@ +package yenon.squadcompanion.logreader + +import com.jcraft.jsch.ChannelSftp +import com.jcraft.jsch.JSch +import com.jcraft.jsch.SftpProgressMonitor +import java.io.PipedInputStream +import java.io.PipedOutputStream + +class LogReaderSftp(val host: String, val port: Int, val file: String, val user: String, val password: String) { + private val pipedInput = PipedInputStream() + private val pipedOutput = PipedOutputStream(pipedInput) + + private var lastSize = 0L + + companion object { + val ignoreProgressMonitor = object : SftpProgressMonitor { + override fun init(op: Int, src: String?, dest: String?, max: Long) { + } + + override fun count(count: Long): Boolean { + return true + } + + override fun end() { + } + } + } + + init { + val jsch = JSch() + val session = jsch.getSession(user, host, port) + session.setPassword(password) + session.connect() + val sftpChannel = session.openChannel("sftp") as ChannelSftp + sftpChannel.connect() + + while (true) { + val newSize = sftpChannel.lstat(file).size + if (newSize < lastSize) { + println("log rotated, resetting") + lastSize = 0L + } + sftpChannel.get(file, pipedOutput, ignoreProgressMonitor, ChannelSftp.RESUME, lastSize) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/LogReader.kt b/src/main/kotlin/yenon/squadcompanion/logreader/LogReaderTail.kt similarity index 92% rename from src/main/kotlin/LogReader.kt rename to src/main/kotlin/yenon/squadcompanion/logreader/LogReaderTail.kt index 6c16a57..1ea3ccc 100644 --- a/src/main/kotlin/LogReader.kt +++ b/src/main/kotlin/yenon/squadcompanion/logreader/LogReaderTail.kt @@ -1,3 +1,5 @@ +package yenon.squadcompanion.logreader + import kotlinx.coroutines.* import java.io.BufferedInputStream import java.io.ByteArrayInputStream @@ -7,7 +9,7 @@ import java.nio.file.Path import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE import kotlin.io.path.name -class LogReader(path: Path, private val lineCallback: (String) -> Unit) : AutoCloseable { +class LogReaderTail(path: Path, private val lineCallback: (String) -> Unit) : AutoCloseable { private var reader: BufferedInputStream private var watchJob: Job private var readerJob: Job @@ -59,7 +61,7 @@ class LogReader(path: Path, private val lineCallback: (String) -> Unit) : AutoCl } } - fun processByteArray(byteArray: ByteArray) { + private fun processByteArray(byteArray: ByteArray) { val lines = String(byteArray).split("\n") stringBuilder.append(lines[0]) diff --git a/src/test/kotlin/WatchTest.kt b/src/test/kotlin/WatchTest.kt index f9f1a1b..f56aeab 100644 --- a/src/test/kotlin/WatchTest.kt +++ b/src/test/kotlin/WatchTest.kt @@ -1,4 +1,5 @@ import org.junit.jupiter.api.Test +import yenon.squadcompanion.logreader.LogReaderTail import java.nio.file.Files import java.nio.file.Paths import kotlin.test.assertEquals @@ -30,7 +31,7 @@ class WatchTest { val targetBackup = Paths.get("testReplace.bak") var index = 0 - val reader = LogReader(target) { + val reader = LogReaderTail(target) { println(it) when (index) { 0 -> {