Rcon rework with timeouts, skeleton implementation of sftp log reader.

This commit is contained in:
yenon 2023-12-07 18:57:00 +01:00
parent 72625833cd
commit c15ab6595e
15 changed files with 470 additions and 354 deletions

2
.idea/kotlinc.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.20" />
<option name="version" value="1.9.21" />
</component>
</project>

View File

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

View File

@ -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<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)
}

View File

@ -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<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()
}
}

View File

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

View File

@ -1,3 +1,5 @@
package yenon.squadcompanion
import java.util.*
object LayerInfoModule : Module() {

View File

@ -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<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 connection = RconConnection("195.201.62.238", 21114, "ThisIsJustATestServer")
val abstraction = 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)
}
}

View File

@ -1,3 +1,5 @@
package yenon.squadcompanion
import java.util.*
abstract class Module {

View File

@ -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<RconAbstraction.SquadListFaction, RconAbstraction.SquadListFaction>
) : Template<HTML> {
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<Int, List<Player>>,
squadListSquad: RconAbstraction.SquadListSquad
squadMembers: Map.Entry<Int, List<RconAbstraction.Player>>,
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 {
if (squadMembers.key != 0) {
+squad.name
} else {
+"Unassigned"
}
if (squadListSquad.locked) {
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<Int, List<Player>>, squadListFaction: RconAbstraction.SquadListFaction) {
private fun FlowContent.makeSide(
team: Map<Int, List<RconAbstraction.Player>>,
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 {

View File

@ -1,7 +1,19 @@
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(
data class ListPlayersOutput(val players: ArrayList<Player>, val disconnected: ArrayList<DisconnectedPlayer>)
data class Player(
val playerId: Int,
val steamId: Long,
val name: String,
@ -9,20 +21,9 @@ data class Player(
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
)
class RconAbstraction(private val connection: RconConnection) {
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

View File

@ -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<RconPacket>()
private val packetsFlow = MutableSharedFlow<RconPacket>(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<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 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()
}
}

View File

@ -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)
}
}
}

View File

@ -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])

View File

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