Compare commits
No commits in common. "e39446cd4743aa4455b2c19dea0e96d6a7dfc378" and "72625833cdd003505f0f680b537c6638472f1658" have entirely different histories.
e39446cd47
...
72625833cd
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.21" />
|
||||
<option name="version" value="1.9.20" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,5 +1,5 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.21"
|
||||
kotlin("jvm") version "1.9.20"
|
||||
}
|
||||
|
||||
group = "org.example"
|
||||
@ -18,8 +18,6 @@ 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 {
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
package yenon.squadcompanion
|
||||
|
||||
object Css {
|
||||
val cssString = """
|
||||
.teamView{
|
||||
@ -8,7 +6,6 @@ object Css {
|
||||
display: flex;
|
||||
}
|
||||
.team{
|
||||
border-radius: 20px;
|
||||
width: 50%;
|
||||
margin: 6px;
|
||||
padding: 6px;
|
||||
@ -20,18 +17,14 @@ 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{
|
||||
padding-left: 10px;
|
||||
background-color: #00000050;
|
||||
border-radius: 5px 5px 0px 0px;
|
||||
background-color: #00000050
|
||||
}
|
||||
.leader{
|
||||
background-color: #CD7F32
|
||||
0
src/main/kotlin/LayerInfo.kt
Normal file
0
src/main/kotlin/LayerInfo.kt
Normal file
@ -1,5 +1,3 @@
|
||||
package yenon.squadcompanion
|
||||
|
||||
import java.util.*
|
||||
|
||||
object LayerInfoModule : Module() {
|
||||
@ -1,5 +1,3 @@
|
||||
package yenon.squadcompanion.logreader
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
@ -9,7 +7,7 @@ import java.nio.file.Path
|
||||
import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE
|
||||
import kotlin.io.path.name
|
||||
|
||||
class LogReaderTail(path: Path, private val lineCallback: (String) -> Unit) : AutoCloseable {
|
||||
class LogReader(path: Path, private val lineCallback: (String) -> Unit) : AutoCloseable {
|
||||
private var reader: BufferedInputStream
|
||||
private var watchJob: Job
|
||||
private var readerJob: Job
|
||||
@ -61,7 +59,7 @@ class LogReaderTail(path: Path, private val lineCallback: (String) -> Unit) : Au
|
||||
}
|
||||
}
|
||||
|
||||
private fun processByteArray(byteArray: ByteArray) {
|
||||
fun processByteArray(byteArray: ByteArray) {
|
||||
val lines = String(byteArray).split("\n")
|
||||
|
||||
stringBuilder.append(lines[0])
|
||||
124
src/main/kotlin/Main.kt
Normal file
124
src/main/kotlin/Main.kt
Normal file
@ -0,0 +1,124 @@
|
||||
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<Module>(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<Throwable> { 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)
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
package yenon.squadcompanion
|
||||
|
||||
import java.util.*
|
||||
|
||||
abstract class Module {
|
||||
@ -1,13 +1,11 @@
|
||||
package yenon.squadcompanion
|
||||
|
||||
import io.ktor.server.html.*
|
||||
import kotlinx.html.*
|
||||
|
||||
class OverviewTemplate(
|
||||
private val players: RconAbstraction.ListPlayersOutput,
|
||||
private val players: ListPlayersOutput,
|
||||
private val squadInfo: Pair<RconAbstraction.SquadListFaction, RconAbstraction.SquadListFaction>
|
||||
) : Template<HTML> {
|
||||
private fun TR.makePlayer(player: RconAbstraction.Player) {
|
||||
private fun TR.makePlayer(player: Player) {
|
||||
td { +player.role }
|
||||
td { +player.name }
|
||||
td { +player.steamId.toString() }
|
||||
@ -25,28 +23,24 @@ class OverviewTemplate(
|
||||
}
|
||||
|
||||
private fun FlowContent.makeSquad(
|
||||
squadMembers: Map.Entry<Int, List<RconAbstraction.Player>>,
|
||||
squad: RconAbstraction.SquadListSquad?
|
||||
squad: Map.Entry<Int, List<Player>>,
|
||||
squadListSquad: RconAbstraction.SquadListSquad
|
||||
) {
|
||||
val leader = squadMembers.value.find { it.leader }
|
||||
val leader = squad.value.find { it.leader }
|
||||
div("squad") {
|
||||
div("squadHeader") {
|
||||
if (squad == null) {
|
||||
+"Squad info not available, refresh page."
|
||||
} else {
|
||||
if (squadMembers.key != 0) {
|
||||
+squad.name
|
||||
if (squad.key != 0) {
|
||||
+squadListSquad.name
|
||||
} else {
|
||||
+"Unassigned"
|
||||
}
|
||||
|
||||
if (squad.locked) {
|
||||
if (squadListSquad.locked) {
|
||||
+" \uD83D\uDD12"
|
||||
}
|
||||
|
||||
if (squad.creatorSteamId != (leader?.steamId ?: 0) && squad.creatorSteamId != 0L) {
|
||||
+" (originally created by ${squad.creatorName} ${squad.creatorSteamId})"
|
||||
}
|
||||
if (squadListSquad.creatorSteamId != (leader?.steamId ?: 0) && squadListSquad.creatorSteamId != 0L) {
|
||||
+" (originally created by ${squadListSquad.creatorName} ${squadListSquad.creatorSteamId})"
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,7 +58,7 @@ class OverviewTemplate(
|
||||
}
|
||||
}
|
||||
}
|
||||
squadMembers.value.forEach {
|
||||
squad.value.forEach {
|
||||
if (!it.leader) {
|
||||
tr {
|
||||
makePlayer(it)
|
||||
@ -75,10 +69,7 @@ class OverviewTemplate(
|
||||
}
|
||||
}
|
||||
|
||||
private fun FlowContent.makeSide(
|
||||
team: Map<Int, List<RconAbstraction.Player>>,
|
||||
squadListFaction: RconAbstraction.SquadListFaction
|
||||
) {
|
||||
private fun FlowContent.makeSide(team: Map<Int, List<Player>>, squadListFaction: RconAbstraction.SquadListFaction) {
|
||||
div("teamHeader") {
|
||||
+squadListFaction.name
|
||||
}
|
||||
@ -88,7 +79,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 {
|
||||
@ -1,16 +1,4 @@
|
||||
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<Player>, val disconnected: ArrayList<DisconnectedPlayer>)
|
||||
data class Player(
|
||||
@ -25,6 +13,17 @@ class RconAbstraction(private val connection: RconConnection) {
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
class RconAbstraction(private val connection: RconConnection) {
|
||||
|
||||
/*
|
||||
Format for "ListPlayers"
|
||||
----- Active Players -----
|
||||
@ -80,7 +79,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
|
||||
182
src/main/kotlin/RconConnection.kt
Normal file
182
src/main/kotlin/RconConnection.kt
Normal file
@ -0,0 +1,182 @@
|
||||
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<Boolean> = withContext(Dispatchers.IO) {
|
||||
socketChannel.close()
|
||||
socketChannel = SocketChannel.open().apply {
|
||||
connect(InetSocketAddress(host, port))
|
||||
finishConnect()
|
||||
println("Socket is open.")
|
||||
}
|
||||
|
||||
authenticate()
|
||||
}
|
||||
|
||||
private fun authenticate(): Result<Boolean> {
|
||||
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<String> = 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<currentId){
|
||||
// responsePacket = readPacket()
|
||||
//}
|
||||
if (responsePacket.id != currentId || responsePacket.type != SERVERDATA_RESPONSE_VALUE) {
|
||||
return@withContext Result.failure(
|
||||
Exception(
|
||||
"Out of order packet. Expected:$currentId, Got:${responsePacket.id}, Data:${
|
||||
String(
|
||||
responsePacket.data,
|
||||
rconCharset
|
||||
)
|
||||
}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (responsePacket.data.size < 4084) {
|
||||
//Short packet, no additional handling needed.
|
||||
return@withContext Result.success(String(responsePacket.data, rconCharset))
|
||||
}
|
||||
|
||||
//Long packet, we need to make sure we read all of it and receive an additional empty packet from Squad.
|
||||
|
||||
val packetByteList = LinkedList<ByteArray>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -1,145 +0,0 @@
|
||||
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 kotlinx.coroutines.runBlocking
|
||||
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<Module>(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 abstraction = runBlocking {
|
||||
val connection = RconConnection("195.201.62.238", 21114, "ThisIsJustATestServer")
|
||||
connection.connect()
|
||||
RconAbstraction(connection)
|
||||
}
|
||||
|
||||
|
||||
embeddedServer(Netty, 8080) {
|
||||
install(StatusPages) {
|
||||
exception<Throwable> { 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)
|
||||
}
|
||||
}
|
||||
@ -1,220 +0,0 @@
|
||||
package yenon.squadcompanion
|
||||
|
||||
import io.ktor.utils.io.core.*
|
||||
import io.ktor.utils.io.errors.*
|
||||
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<RconPacket>()
|
||||
private val packetsFlow = MutableSharedFlow<RconPacket>(extraBufferCapacity = 8)
|
||||
|
||||
suspend fun connect(): ConnectionStatus {
|
||||
val status = reconnect()
|
||||
if (status != ConnectionStatus.CONNECTED) {
|
||||
return status
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
return ConnectionStatus.CONNECTED
|
||||
}
|
||||
|
||||
enum class ConnectionStatus {
|
||||
CONNECTED, WRONG_PASSWORD, TIMEOUT
|
||||
}
|
||||
|
||||
private suspend fun reconnect(): ConnectionStatus = withContext(Dispatchers.IO) {
|
||||
socketChannel.close()
|
||||
try {
|
||||
socketChannel = SocketChannel.open().apply {
|
||||
connect(InetSocketAddress(host, port))
|
||||
finishConnect()
|
||||
println("Socket is open.")
|
||||
}
|
||||
} catch (ex: IOException) {
|
||||
return@withContext ConnectionStatus.TIMEOUT
|
||||
}
|
||||
|
||||
return@withContext if (authenticate()) {
|
||||
ConnectionStatus.CONNECTED
|
||||
} else {
|
||||
ConnectionStatus.WRONG_PASSWORD
|
||||
}
|
||||
}
|
||||
|
||||
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<String> = 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 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()
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
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
|
||||
@ -31,7 +30,7 @@ class WatchTest {
|
||||
val targetBackup = Paths.get("testReplace.bak")
|
||||
|
||||
var index = 0
|
||||
val reader = LogReaderTail(target) {
|
||||
val reader = LogReader(target) {
|
||||
println(it)
|
||||
when (index) {
|
||||
0 -> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user