Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.runtime)

// Ktor server dependencies
implementation("io.ktor:ktor-server-core:2.3.6")
implementation("io.ktor:ktor-server-netty:2.3.6")
implementation("io.ktor:ktor-server-content-negotiation:2.3.6")
implementation("io.ktor:ktor-serialization-jackson:2.3.6")
implementation("ch.qos.logback:logback-classic:1.4.11")

// Testing
testImplementation(kotlin("test"))
Expand Down
16 changes: 15 additions & 1 deletion src/main/kotlin/org/game2048/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,35 @@ import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.AnimatedVisibility
import org.game2048.api.CommandApi
import org.game2048.engine.GameEngine
import org.game2048.engine.GameEngineImpl
import org.game2048.model.Board
import org.game2048.model.GameState
import org.game2048.model.Move

fun main() = application {
// Initialize and start the command API server
val commandApi = remember { CommandApi(port = 8080) }

DisposableEffect(Unit) {
commandApi.start()
onDispose {
commandApi.stop()
}
}

val windowState = remember {
WindowState(
size = DpSize(550.dp, 700.dp)
)
}

Window(
onCloseRequest = ::exitApplication,
onCloseRequest = {
commandApi.stop()
exitApplication()
},
title = "2048 Game",
state = windowState,
resizable = true,
Expand Down
199 changes: 199 additions & 0 deletions src/main/kotlin/org/game2048/api/CommandApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package org.game2048.api

import io.ktor.http.*
import io.ktor.serialization.jackson.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit

/**
* Data class representing a command execution request.
*/
data class CommandRequest(
val command: String,
val timeoutSeconds: Int = 30
) {
/**
* Validates the request parameters.
* @throws IllegalArgumentException if validation fails
*/
fun validate() {
require(command.isNotBlank()) { "Command cannot be empty" }
require(timeoutSeconds in 1..300) { "Timeout must be between 1 and 300 seconds" }
}
}

/**
* Data class representing a command execution response.
*/
data class CommandResponse(
val output: String,
val exitCode: Int,
val error: String? = null
)

/**
* Class responsible for setting up and managing the command execution API.
*/
class CommandApi(private val port: Int = 8080) {
private var server: ApplicationEngine? = null
private val logger = LoggerFactory.getLogger(CommandApi::class.java)

// List of potentially dangerous commands that should be blocked
private val blockedCommands = listOf(
"rm -rf", "mkfs", "dd", ":(){ :|:& };:", "chmod -R 777 /",
"> /dev/sda", "/dev/null", "mv / /dev/null"
)

/**
* Starts the API server.
*/
fun start() {
server = embeddedServer(Netty, port = port) {
install(ContentNegotiation) {
jackson()
}

routing {
route("/api") {
post("/execute") {
try {
val request = call.receive<CommandRequest>()

// Validate request
try {
request.validate()
} catch (e: IllegalArgumentException) {
logger.warn("Invalid request: ${e.message}")
call.respond(
HttpStatusCode.BadRequest,
CommandResponse(
output = "",
exitCode = -1,
error = "Invalid request: ${e.message}"
)
)
return@post
}

// Check for dangerous commands
if (isCommandBlocked(request.command)) {
logger.warn("Blocked potentially dangerous command: ${request.command}")
call.respond(
HttpStatusCode.Forbidden,
CommandResponse(
output = "",
exitCode = -1,
error = "Command execution blocked for security reasons"
)
)
return@post
}

logger.info("Executing command: ${request.command}")
val result = executeCommand(request.command, request.timeoutSeconds)
call.respond(HttpStatusCode.OK, result)
} catch (e: Exception) {
logger.error("Error processing request", e)
call.respond(
HttpStatusCode.InternalServerError,
CommandResponse(
output = "",
exitCode = -1,
error = "Error executing command: ${e.message}"
)
)
}
}
}
}
}.start(wait = false)

logger.info("Command API server started on port $port")
println("Command API server started on port $port")
}

/**
* Stops the API server.
*/
fun stop() {
server?.stop(1, 5, TimeUnit.SECONDS)
logger.info("Command API server stopped")
println("Command API server stopped")
}

/**
* Checks if a command contains blocked patterns.
*
* @param command The command to check
* @return true if the command is blocked, false otherwise
*/
private fun isCommandBlocked(command: String): Boolean {
val lowercaseCommand = command.lowercase()
return blockedCommands.any { blockedCmd ->
lowercaseCommand.contains(blockedCmd.lowercase())
}
}

/**
* Executes an OS-level command and returns the result.
*
* @param command The command to execute
* @param timeoutSeconds Timeout in seconds
* @return CommandResponse containing the output and exit code
*/
private suspend fun executeCommand(command: String, timeoutSeconds: Int): CommandResponse {
return withContext(Dispatchers.IO) {
try {
logger.debug("Starting command execution: $command")
val process = ProcessBuilder()
.command("sh", "-c", command)
.redirectErrorStream(true)
.start()

val output = StringBuilder()
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
output.append(line).append("\n")
}
}

val completed = process.waitFor(timeoutSeconds.toLong(), TimeUnit.SECONDS)
if (!completed) {
process.destroy()
logger.warn("Command execution timed out after $timeoutSeconds seconds: $command")
return@withContext CommandResponse(
output = output.toString(),
exitCode = -1,
error = "Command execution timed out after $timeoutSeconds seconds"
)
}

val exitCode = process.exitValue()
logger.debug("Command completed with exit code $exitCode: $command")
CommandResponse(
output = output.toString(),
exitCode = exitCode
)
} catch (e: Exception) {
logger.error("Error executing command: $command", e)
CommandResponse(
output = "",
exitCode = -1,
error = "Error executing command: ${e.message}"
)
}
}
}
}
113 changes: 113 additions & 0 deletions src/test/kotlin/org/game2048/api/CommandApiTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package org.game2048.api

import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.registerKotlinModule

class CommandApiTest {
private lateinit var commandApi: CommandApi
private val testPort = 8081
private val objectMapper = ObjectMapper().registerKotlinModule()
private val httpClient = HttpClient.newBuilder().build()

@BeforeEach
fun setup() {
commandApi = CommandApi(port = testPort)
commandApi.start()
// Give the server a moment to start
Thread.sleep(500)
}

@AfterEach
fun tearDown() {
commandApi.stop()
// Give the server a moment to stop
Thread.sleep(500)
}

@Test
fun `test execute simple command`() = runBlocking {
// Create request payload
val requestBody = objectMapper.writeValueAsString(CommandRequest(
command = "echo 'Hello, World!'",
timeoutSeconds = 5
))

// Send request to API
val request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:$testPort/api/execute"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

// Verify response
assertEquals(200, response.statusCode())

val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java)
assertEquals(0, commandResponse.exitCode)
assertTrue(commandResponse.output.contains("Hello, World!"))
assertNull(commandResponse.error)
}

@Test
fun `test execute invalid command`() = runBlocking {
// Create request payload with empty command
val requestBody = objectMapper.writeValueAsString(CommandRequest(
command = "",
timeoutSeconds = 5
))

// Send request to API
val request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:$testPort/api/execute"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

// Verify response
assertEquals(400, response.statusCode())

val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java)
assertEquals(-1, commandResponse.exitCode)
assertNotNull(commandResponse.error)
assertTrue(commandResponse.error!!.contains("Command cannot be empty"))
}

@Test
fun `test blocked command`() = runBlocking {
// Create request payload with a blocked command
val requestBody = objectMapper.writeValueAsString(CommandRequest(
command = "rm -rf /some/path",
timeoutSeconds = 5
))

// Send request to API
val request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:$testPort/api/execute"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

// Verify response
assertEquals(403, response.statusCode())

val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java)
assertEquals(-1, commandResponse.exitCode)
assertNotNull(commandResponse.error)
assertTrue(commandResponse.error!!.contains("blocked for security reasons"))
}
}