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 -> {